From 87750b7bcb3b975373e519ed827d4fd1507229b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20G=C3=BCney=20Y=C4=B1ld=C4=B1r=C4=B1m?= Date: Mon, 28 Sep 2026 02:37:10 +0300 Subject: [PATCH 01/49] Fix duration calculation for agent stop command Implement negative duration protection against clock skew to prevent invalid timestamp subtraction. --- cli/src/commands/agent_stop.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/agent_stop.rs b/cli/src/commands/agent_stop.rs index 4d617c42d70b..744e50c2c07d 100644 --- a/cli/src/commands/agent_stop.rs +++ b/cli/src/commands/agent_stop.rs @@ -80,7 +80,16 @@ pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result= started_ms { + now_ms - started_ms + } else { + 0 + }; + let turn_id = active_turn.id; debug!(ctx.log, "Cancelling turn {} on {}", turn_id, chat_uri); From ed14fd3d86945da3bc16c7285b68d58f1a1a4c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20G=C3=BCney=20Y=C4=B1ld=C4=B1r=C4=B1m?= Date: Mon, 28 Sep 2026 02:45:14 +0300 Subject: [PATCH 02/49] Translate comment to English for clarity --- cli/src/commands/agent_stop.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/agent_stop.rs b/cli/src/commands/agent_stop.rs index 744e50c2c07d..f78696b55cac 100644 --- a/cli/src/commands/agent_stop.rs +++ b/cli/src/commands/agent_stop.rs @@ -81,7 +81,7 @@ pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result= started_ms { From bdcbdeedde44a19244b6ad74edbd1848985c6d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20G=C3=BCney=20Y=C4=B1ld=C4=B1r=C4=B1m?= Date: Tue, 29 Sep 2026 19:53:08 +0300 Subject: [PATCH 03/49] Refactor duration calculation to use max function --- cli/src/commands/agent_stop.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cli/src/commands/agent_stop.rs b/cli/src/commands/agent_stop.rs index f78696b55cac..c5a033c7ecbb 100644 --- a/cli/src/commands/agent_stop.rs +++ b/cli/src/commands/agent_stop.rs @@ -84,11 +84,7 @@ pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result= started_ms { - now_ms - started_ms - } else { - 0 - }; + let duration = std::cmp::max(0, now_ms - started_ms); let turn_id = active_turn.id; From 0817aaa824590067854c361e77eb973a08b3cf48 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sun, 4 Oct 2026 03:12:49 +0200 Subject: [PATCH 04/49] sessions: Persist read state per chat (#339500) * sessions: persist and display per-chat read state Store peer read state in the Agent Host chat catalog, restore missing state as read, and project each chat's status throughout the Agents Window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agent host: repair missing chat catalog columns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: complete multi-chat read state support Adopt the merged AHP compact chat status contract, persist exact read state for every chat, and keep the session aggregate consistent across activation, archive, restore, and explicit read operations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: target read actions at main chat Make per-session Mark as Read and Mark as Unread follow the main chat state shown by the row while preserving the independent session aggregate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: align read state tests with protocol Update database fixtures, unit expectations, and AHP snapshots for the current payload version and per-chat read-state transitions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: complete rename chat fixtures Provide exact chat read state for every rename-test peer fixture used by the Sessions list renderer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: preserve read state while restoring chats Allow default-chat read updates before session-state hydration and preserve legacy session read state when per-chat metadata is absent.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: discard stale read-state cache Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: test recovered peer membership precisely Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: stabilize read-state CI checks Wait for the terminal chat unread action before E2E turns are treated as drained, and complete component-fixture main-chat read-state mocks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address read-state review feedback Exclude hidden chats from aggregate read state, preserve recovered peer read metadata, and retry automatic read transitions after provider startup no-ops. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: stabilize unread E2E synchronization Wait for the current turn's unread action after its terminal notification, while allowing host-local turns to opt out of an action they intentionally do not emit. Regenerate AHP traffic snapshots so read-state changes remain in the originating round. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: stabilize Windows Codex E2E checks Regenerate the Windows-only consecutive-turn snapshot with unread actions in their originating rounds. Match the selected-model assertion to its prompt-bearing request so later background requests cannot change the observed model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostProtocolClient.ts | 3 +- src/vs/platform/agentHost/common/agent.ts | 2 + .../agentHost/common/state/sessionState.ts | 19 +- .../node/agentHostCatalogListReader.ts | 1 + .../node/agentHostCatalogProjection.ts | 3 +- .../node/agentHostCatalogSourceResolver.ts | 17 +- .../agentHost/node/agentHostDatabase.ts | 48 ++- .../agentHost/node/agentHostPeerChatStore.ts | 29 +- .../agentHost/node/agentHostStateManager.ts | 80 +++- .../platform/agentHost/node/agentService.ts | 91 ++++- .../builtInChatContributions.ts | 2 + .../chatArchive/chatArchiveContribution.ts | 7 +- .../chatRead/chatReadContribution.ts | 48 +++ .../markUnread/markUnreadContribution.ts | 16 +- .../agentHost/node/protocolServerHandler.ts | 9 +- .../agentHostProtocolClient.test.ts | 4 +- .../node/agentHostCatalogListReader.test.ts | 5 +- .../node/agentHostCatalogProjection.test.ts | 2 +- .../agentHostCatalogSourceResolver.test.ts | 64 ++- .../test/node/agentHostDatabase.test.ts | 61 ++- .../test/node/agentHostPeerChatStore.test.ts | 26 +- .../test/node/agentHostStateManager.test.ts | 208 +++++++++- .../node/agentHostToolCallTelemetry.test.ts | 1 + .../node/agentHostTurnHangTelemetry.test.ts | 1 + .../test/node/agentHostTurnTelemetry.test.ts | 1 + .../agentHost/test/node/agentService.test.ts | 208 +++++++++- .../test/node/agentSideEffects.test.ts | 13 +- .../test/node/chatContributions.test.ts | 168 +++++++- .../e2e/harness/agentHostE2ETestHarness.ts | 23 +- ...de_counts_lines_in_a_file.traffic.ahp.yaml | 3 + ...in_a_new_nested_directory.traffic.ahp.yaml | 3 + ...e_creates_a_new_text_file.traffic.ahp.yaml | 3 + ..._deletes_a_workspace_file.traffic.ahp.yaml | 3 + ...its_an_existing_text_file.traffic.ahp.yaml | 3 + ...e_without_a_session_error.traffic.ahp.yaml | 3 + ...laude_inspects_git_status.traffic.ahp.yaml | 3 + ...e_lists_workspace_entries.traffic.ahp.yaml | 3 + ...e_from_a_nested_directory.traffic.ahp.yaml | 3 + ...ilename_containing_spaces.traffic.ahp.yaml | 3 + ...e_reads_a_value_from_JSON.traffic.ahp.yaml | 3 + ...ads_an_existing_text_file.traffic.ahp.yaml | 3 + ..._renames_a_workspace_file.traffic.ahp.yaml | 3 + ..._across_consecutive_turns.traffic.ahp.yaml | 6 + ...terministic_shell_command.traffic.ahp.yaml | 3 + ...in_a_new_nested_directory.traffic.ahp.yaml | 3 + ...x_creates_a_new_text_file.traffic.ahp.yaml | 3 + ..._deletes_a_workspace_file.traffic.ahp.yaml | 3 + ...its_an_existing_text_file.traffic.ahp.yaml | 3 + ...ilename_containing_spaces.traffic.ahp.yaml | 3 + ..._renames_a_workspace_file.traffic.ahp.yaml | 3 + ..._across_consecutive_turns.traffic.ahp.yaml | 6 + ...es_a_failed_turn_in_place.traffic.ahp.yaml | 3 + ...mains_a_non-terminal_tool.traffic.ahp.yaml | 3 + ...ot_counts_lines_in_a_file.traffic.ahp.yaml | 3 + ...in_a_new_nested_directory.traffic.ahp.yaml | 3 + ...t_creates_a_new_text_file.traffic.ahp.yaml | 3 + ..._deletes_a_workspace_file.traffic.ahp.yaml | 3 + ...its_an_existing_text_file.traffic.ahp.yaml | 3 + ...e_without_a_session_error.traffic.ahp.yaml | 3 + ...pilot_inspects_git_status.traffic.ahp.yaml | 3 + ...t_lists_workspace_entries.traffic.ahp.yaml | 3 + ...e_from_a_nested_directory.traffic.ahp.yaml | 3 + ...ilename_containing_spaces.traffic.ahp.yaml | 3 + ...t_reads_a_value_from_JSON.traffic.ahp.yaml | 3 + ...ads_an_existing_text_file.traffic.ahp.yaml | 3 + ..._renames_a_workspace_file.traffic.ahp.yaml | 3 + ..._repeated_follow-up_turns.traffic.ahp.yaml | 9 + ..._across_consecutive_turns.traffic.ahp.yaml | 6 + ...terministic_shell_command.traffic.ahp.yaml | 3 + ..._before_the_shell_command.traffic.ahp.yaml | 3 + ...copilotOtelAgentHostE2E.integrationTest.ts | 2 +- .../test/node/e2e/suites/annotationsSuite.ts | 2 +- .../test/node/e2e/suites/coreSuite.ts | 11 +- .../test/node/e2e/suites/multiChatSuite.ts | 2 +- .../test/node/e2e/suites/serverToolsSuite.ts | 1 + .../e2e/suites/sessionPersistenceSuite.ts | 2 +- .../test/node/protocolServerHandler.test.ts | 17 +- src/vs/sessions/SESSIONS.md | 2 + .../browser/parts/chatCompositeBar.ts | 4 +- src/vs/sessions/common/contextkeys.ts | 2 +- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 4 +- .../browser/baseAgentHostSessionsProvider.ts | 260 +++++++++++- .../browser/localAgentHostSessionsProvider.ts | 6 +- .../localAgentHostSessionsProvider.test.ts | 374 +++++++++++++++++- .../remoteAgentHostSessionsProvider.ts | 6 +- .../sessions/browser/media/sessionsList.css | 1 + .../sessions/browser/views/sessionsList.ts | 49 ++- .../browser/views/sessionsViewActions.ts | 4 +- .../test/browser/automationsView.fixture.ts | 1 + .../test/browser/automationsView.test.ts | 2 + .../test/browser/sessionsActions.test.ts | 29 ++ .../test/browser/sessionsList.test.ts | 173 +++++++- .../browser/sessionsListContextMenu.test.ts | 2 + .../test/browser/sessionsListFixtureUtils.ts | 4 +- .../test/browser/sessionsListTestUtils.ts | 3 +- .../test/browser/sessionsRename.test.ts | 5 + .../browser/sessionsManagementService.ts | 69 +++- .../sessions/browser/sessionsService.ts | 132 ++++--- .../sessions/common/sessionContextKeys.ts | 2 +- .../sessions/common/sessionsManagement.ts | 25 +- .../sessions/common/sessionsProvider.ts | 7 + .../test/browser/sessionNavigation.test.ts | 1 + .../browser/sessionsManagementService.test.ts | 324 ++++++++++++--- .../test/common/sessionContextKeys.test.ts | 31 +- .../test/common/testSessionBuilder.ts | 11 +- .../test/browser/chatCompositeBar.test.ts | 33 +- .../sessions/blockedSessionsList.fixture.ts | 1 + 107 files changed, 2595 insertions(+), 307 deletions(-) create mode 100644 src/vs/platform/agentHost/node/chatContributions/chatRead/chatReadContribution.ts diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 52fb661bb5c7..508e63c06e24 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -32,7 +32,7 @@ import { AGENT_HOST_SCHEME, agentHostAuthority, createAgentHostResourceUriMapper import { AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../common/agentHostResourceService.js'; import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest, JsonRpcResponse } from '../common/state/protocol/messages.js'; 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 { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isDefaultChatUri, isSessionStatusArchived, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; +import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCompatibility.js'; import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; @@ -1852,6 +1852,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect origin: chat.origin, ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), ...(isSessionStatusArchived(chat.status) || chat.archived === true ? { archived: true } : {}), + ...(chat.status !== undefined ? { isRead: isSessionStatusRead(chat.status) } : {}), ...(chat.changes !== undefined ? { changes: chat.changes } : {}), })), // Carry durable host provenance for sessions first materialized from a listing. diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 0e95c118e6af..d4ad96adc8e5 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -193,6 +193,8 @@ export interface IAgentSessionChatMetadata { readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; readonly archived?: boolean; + /** Exact chat read state when known; absence means the provider did not supply it. */ + readonly isRead?: boolean; readonly changes?: ChangesSummary; } diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 9d19c69acc02..815159df4560 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -41,6 +41,7 @@ import { type ChangesetState, type ChatState, type ChatSummary, + type ChatOrigin, type CanvasState, type ErrorInfo, type ErrorResponsePart, @@ -2029,12 +2030,14 @@ export const AH_META_AUTO_ARCHIVED_AT_DB_KEY = 'agentHost.autoArchivedAt'; export const AH_META_IS_DONE_DB_KEY = 'isDone'; /** - * Session-database metadata key recording whether a session has been read. This is - * the only durable representation of read state; the in-memory truth is - * {@link SessionStatus.IsRead}. The host owns it — no agent SDK tracks read state. + * Session-database metadata key recording the session aggregate read state. The + * in-memory truth is {@link SessionStatus.IsRead}; chat state is stored separately. */ export const AH_META_IS_READ_DB_KEY = 'isRead'; +/** Session-database metadata key recording the default chat's independent read state. */ +export const AH_META_DEFAULT_CHAT_IS_READ_DB_KEY = 'defaultChatIsRead'; + /** Returns `status` with `flag` set or cleared. */ export function withSessionStatusFlag(status: SessionStatus, flag: SessionStatus, set: boolean): SessionStatus { return set ? (status | flag) : (status & ~flag); @@ -2045,6 +2048,16 @@ export function isSessionStatusRead(status: SessionStatus | undefined): boolean return status !== undefined && (status & SessionStatus.IsRead) !== 0; } +/** + * Whether a chat participates in the containing session's aggregate read state. + * Tool/subagent/hidden chats retain exact per-chat state without making the session unread. + */ +export function isChatInSessionReadAggregate(resource: ProtocolURI, origin?: ChatOrigin, interactivity?: ChatInteractivity): boolean { + return origin?.kind !== ChatOriginKind.Tool + && interactivity !== ChatInteractivity.Hidden + && !isSubagentChatUri(resource); +} + /** Whether the {@link SessionStatus.IsArchived} flag bit is set. */ export function isSessionStatusArchived(status: SessionStatus | undefined): boolean { return status !== undefined && (status & SessionStatus.IsArchived) !== 0; diff --git a/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts b/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts index 981977a3e0d8..417bf5f965e2 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts @@ -134,6 +134,7 @@ export class AgentHostCatalogListReader { origin: fromCatalogChatOrigin(chat.origin), ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), ...(chat.archived === true ? { archived: true } : {}), + ...(chat.isRead !== undefined ? { isRead: chat.isRead } : {}), ...(chat.changes !== undefined ? { changes: chat.changes } : {}), })), ...(meta !== undefined ? { _meta: meta } : {}), diff --git a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts index 08affa678028..e5c3fc6dbe12 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -15,7 +15,7 @@ import { SESSION_META_ARTIFACTS_KEY } from '../common/sessionArtifacts.js'; import { ChatInteractivity } from '../common/state/protocol/channels-chat/state.js'; import { SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_GITHUB_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY } from '../common/state/sessionState.js'; -export const AGENT_HOST_CATALOG_PAYLOAD_VERSION = 1; +export const AGENT_HOST_CATALOG_PAYLOAD_VERSION = 2; export const AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT = 10; export const AGENT_HOST_CATALOG_ARTIFACT_LIMIT = 100; export const AGENT_HOST_CATALOG_CHILD_LIMIT = 1000; @@ -385,6 +385,7 @@ const chatValidator = plainObject(vObj({ origin: vOptionalProp(jsonValue()), interactivity: vOptionalProp(vEnum(ChatInteractivity.Full, ChatInteractivity.ReadOnly, ChatInteractivity.Hidden)), archived: vOptionalProp(vBoolean()), + isRead: vOptionalProp(vBoolean()), inheritedTurnId: vOptionalProp(boundedString(AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT)), workingDirectories: vOptionalProp(workingDirectoriesValidator), changes: vOptionalProp(agentHostCatalogChangesValidator), diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index a085c486ca7e..0e26f8104c62 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -12,7 +12,7 @@ import { parseSessionArtifacts, readSessionArtifacts, SESSION_META_ARTIFACTS_KEY import { getChatChangesSummaryMetadataKey, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { META_GIT_DATA_STATE, META_GIT_STATE, META_GITHUB_DATA_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; import { ChangesSummary, ChatInteractivity, ChatOrigin, ChatOriginKind } from '../common/state/protocol/state.js'; -import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ISessionGitHubState, ISessionGitState, ISessionSourceControlState, parseSessionCreationReference, parseSessionFolderPickerDecision, parseSessionMultiRootMetadata, readSessionCreationReference, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionExternal, readSessionFolderPickerDecision, parseSessionGitHubData, parseSessionGitHubState, readSessionGitData, readSessionGitHubData, readSessionGitState, withMigratedSessionGitHubState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionStatus, SessionSummary } from '../common/state/sessionState.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ISessionGitHubState, ISessionGitState, ISessionSourceControlState, isChatInSessionReadAggregate, parseSessionCreationReference, parseSessionFolderPickerDecision, parseSessionMultiRootMetadata, readSessionCreationReference, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionExternal, readSessionFolderPickerDecision, parseSessionGitHubData, parseSessionGitHubState, readSessionGitData, readSessionGitHubData, readSessionGitState, withMigratedSessionGitHubState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionStatus, SessionSummary } from '../common/state/sessionState.js'; import { AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT, AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, AgentHostCatalogData, AgentHostCatalogJsonValue, AgentHostCatalogMetadata, agentHostCatalogChangesValidator, agentHostCatalogGitDataValidator, agentHostCatalogGitValidator } from './agentHostCatalogProjection.js'; import { IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; import { AGENT_HOST_TITLE_SOURCE_AUTO, AgentHostTitleSource, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; @@ -35,6 +35,7 @@ export interface ICatalogSourceState { readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; readonly archived?: boolean; + readonly isRead?: boolean; readonly inheritedTurnId?: string; readonly workingDirectories?: readonly string[]; readonly changes?: ChangesSummary; @@ -85,6 +86,7 @@ const sessionMetadata = { title: stringSessionMetadataKey(SESSION_CUSTOM_TITLE_KEY), titleSource: stringSessionMetadataKey(SESSION_CUSTOM_TITLE_SOURCE_KEY), isRead: parsedSessionMetadataKey(AH_META_IS_READ_DB_KEY, value => value === 'true'), + defaultChatIsRead: parsedSessionMetadataKey(AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, value => value === 'true'), isArchived: parsedSessionMetadataKey(AH_META_IS_ARCHIVED_DB_KEY, value => value === 'true'), isDone: parsedSessionMetadataKey(AH_META_IS_DONE_DB_KEY, value => value === 'true'), creationReference: parsedSessionMetadataKey(AH_META_CREATED_BY_SESSION_DB_KEY, parseSessionCreationReference), @@ -200,9 +202,18 @@ export class AgentHostCatalogSourceResolver { ? persistedWorkspaceless : readSessionWorkspaceless(state.meta) || persistedWorkspaceless; const stateIsRead = (state.status & SessionStatus.IsRead) !== 0; - const isRead = preferPersistedMetadata && metadata[AH_META_IS_READ_DB_KEY] !== undefined + const persistedSessionIsRead = preferPersistedMetadata && metadata[AH_META_IS_READ_DB_KEY] !== undefined ? sessionMetadata.isRead.read(metadata) ?? false : stateIsRead; + const chatIsRead = (chat: ICatalogSourceState['chats'][number]): boolean | undefined => preferPersistedMetadata + && chat.kind === 'default' + && sessionMetadata.defaultChatIsRead.has(metadata) + ? sessionMetadata.defaultChatIsRead.read(metadata) + : chat.isRead; + const aggregateChats = state.chats.filter(chat => isChatInSessionReadAggregate(chat.uri, chat.origin, chat.interactivity)); + const isRead = aggregateChats.length === 1 && aggregateChats[0].isRead !== undefined + ? chatIsRead(aggregateChats[0]) ?? persistedSessionIsRead + : persistedSessionIsRead && !aggregateChats.some(chat => chatIsRead(chat) === false); const persistedArchived = sessionMetadata.isArchived.read(metadata) ?? sessionMetadata.isDone.read(metadata); const isArchived = preferPersistedMetadata && persistedArchived !== undefined ? persistedArchived @@ -261,6 +272,7 @@ export class AgentHostCatalogSourceResolver { // temporary restriction must not become permanent after a restart. const interactivity = chat.interactivity === ChatInteractivity.ReadOnly && readChatInputState({ _meta: state.meta }, chat.uri) ? ChatInteractivity.Full : chat.interactivity; + const isRead = chatIsRead(chat); const persistedChatChanges = readPersistedChanges(metadata[getChatChangesSummaryMetadataKey(chat.uri)]); const chatChanges = preferPersistedMetadata ? persistedChatChanges ?? chat.changes @@ -274,6 +286,7 @@ export class AgentHostCatalogSourceResolver { origin: toCatalogChatOrigin(chat.origin), ...(interactivity !== undefined ? { interactivity } : {}), ...(chat.archived === true ? { archived: true } : {}), + ...(isRead !== undefined ? { isRead } : {}), ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), ...(chat.workingDirectories !== undefined ? { workingDirectories: chat.workingDirectories } : {}), ...(chatChanges !== undefined ? { changes: chatChanges } : {}), diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index afa4e9536ebe..f74d442a36e0 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -100,6 +100,7 @@ export interface IAgentHostDatabaseSessionV2 extends IAgentHostDatabaseSessionV2 export interface IAgentHostDatabaseSessionChat { readonly chat: string; readonly order: number; + readonly isRead?: boolean; readonly archived?: boolean; readonly providerData?: string; readonly origin?: string; @@ -281,6 +282,9 @@ const sessionChatCatalogSchemaSql = [ ].join(';\n'); const CHAT_ARCHIVE_MIGRATION_VERSION = 12; +const CHAT_READ_MIGRATION_VERSION = 13; +const chatArchiveMigrationSql = 'ALTER TABLE session_chats ADD COLUMN archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1))'; +const chatReadMigrationSql = 'ALTER TABLE session_chats ADD COLUMN is_read INTEGER CHECK (is_read IN (0, 1))'; const migrations = [ { @@ -328,23 +332,52 @@ const migrations = [ // Versions 6 through 11 were used by pre-release catalog schemas and are // normalized above, so new migrations resume at 12. version: CHAT_ARCHIVE_MIGRATION_VERSION, - sql: 'ALTER TABLE session_chats ADD COLUMN archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1))', + sql: chatArchiveMigrationSql, + }, + { + version: CHAT_READ_MIGRATION_VERSION, + sql: chatReadMigrationSql, }, ] as const; async function normalizePreReleaseCatalogSchema(database: Database, currentVersion: number): Promise { - if (currentVersion < 4 || currentVersion > 11 || !await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sessions_v2'`, [])) { + if (currentVersion < 4 || !await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sessions_v2'`, [])) { return currentVersion; } const hasFinalCatalog = await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'session_chat_catalogs'`, []) && await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'session_chats'`, []); - if (hasFinalCatalog && currentVersion >= 5 && currentVersion < CHAT_ARCHIVE_MIGRATION_VERSION) { + if (hasFinalCatalog && currentVersion >= 5) { const chatColumns = await all(database, 'PRAGMA table_info(session_chats)', []); - if (chatColumns.some(column => column.name === 'archived')) { + const hasArchived = chatColumns.some(column => column.name === 'archived'); + const hasRead = chatColumns.some(column => column.name === 'is_read'); + if (currentVersion >= CHAT_READ_MIGRATION_VERSION) { + // Temporary compatibility for development profiles shared by worktrees + // whose pre-release schema versions can advance independently. + if (!hasArchived) { + await exec(database, chatArchiveMigrationSql); + } + if (!hasRead) { + await exec(database, chatReadMigrationSql); + } + return currentVersion; + } + if (hasArchived && hasRead) { + await exec(database, `PRAGMA user_version = ${CHAT_READ_MIGRATION_VERSION}`); + return CHAT_READ_MIGRATION_VERSION; + } + if (hasRead) { + await exec(database, chatArchiveMigrationSql); + await exec(database, `PRAGMA user_version = ${CHAT_READ_MIGRATION_VERSION}`); + return CHAT_READ_MIGRATION_VERSION; + } + if (hasArchived) { await exec(database, `PRAGMA user_version = ${CHAT_ARCHIVE_MIGRATION_VERSION}`); return CHAT_ARCHIVE_MIGRATION_VERSION; } } + if (currentVersion >= CHAT_READ_MIGRATION_VERSION) { + return currentVersion; + } const isPreReleaseVersion11 = currentVersion === 11; if (hasFinalCatalog && currentVersion >= 5 && !isPreReleaseVersion11) { return currentVersion; @@ -1290,6 +1323,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { (SELECT value FROM metadata WHERE key = ?) AS legacy_mirrored_payload, chat.chat_uri, chat.chat_order, + chat.is_read, chat.archived, chat.provider_data, chat.origin, @@ -1309,6 +1343,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { chats: rows.filter(row => row.chat_uri !== null).map(row => ({ chat: row.chat_uri as string, order: row.chat_order as number, + ...(row.is_read === null ? {} : { isRead: row.is_read === 1 }), ...(row.archived === 1 ? { archived: true } : {}), ...(row.provider_data === null ? {} : { providerData: row.provider_data as string }), ...(row.origin === null ? {} : { origin: row.origin as string }), @@ -1375,11 +1410,12 @@ export class AgentHostDatabase implements IAgentHostDatabase { for (let offset = 0; offset < chats.length; offset += SESSION_CHAT_INSERT_BATCH_SIZE) { const batch = chats.slice(offset, offset + SESSION_CHAT_INSERT_BATCH_SIZE); await run(database, `INSERT INTO session_chats ( - session_uri, chat_uri, chat_order, archived, provider_data, origin, inherited_turn_id - ) VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')}`, batch.flatMap(chat => [ + session_uri, chat_uri, chat_order, is_read, archived, provider_data, origin, inherited_turn_id + ) VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}`, batch.flatMap(chat => [ session, chat.chat, chat.order, + chat.isRead === undefined ? null : chat.isRead ? 1 : 0, chat.archived === true ? 1 : 0, chat.providerData ?? null, chat.origin ?? null, diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index 8c9673dacd80..88c5fe60be3c 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -13,7 +13,7 @@ import { ILogService } from '../../log/common/log.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import type { AgentHostCatalogDatabaseReference } from './agentHostCatalogSyncService.js'; import { ChatOrigin } from '../common/state/protocol/state.js'; -import { buildChatUri, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js'; +import { AH_META_IS_READ_DB_KEY, buildChatUri, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js'; import { fromCatalogChatOrigin, toSerializableJsonValue } from './agentHostCatalogSourceResolver.js'; import { AGENT_HOST_CATALOG_CHILD_LIMIT } from './agentHostCatalogProjection.js'; import { IAgentHostDatabase } from './agentHostDatabase.js'; @@ -31,11 +31,13 @@ export const IAgentHostPeerChatPersistenceService = createDecorator; setArchived(session: URI, chat: URI, archived: boolean): Promise; } export interface IPersistedPeerChat { readonly uri: string; + readonly isRead?: boolean; readonly archived?: boolean; readonly providerData?: string; readonly origin?: ChatOrigin; @@ -389,6 +391,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ const next = entries.filter(entry => entry.uri !== chatUri); next.push({ uri: chatUri, + ...(existing?.isRead !== undefined ? { isRead: existing.isRead } : {}), ...(existing?.archived ? { archived: true } : {}), ...(providerData !== undefined ? { providerData } : {}), ...(effectiveOrigin !== undefined ? { origin: effectiveOrigin } : {}), @@ -406,6 +409,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ const next = entries.filter(entry => entry.uri !== chatUri); next.push({ uri: chatUri, + ...(existing?.isRead !== undefined ? { isRead: existing.isRead } : {}), ...(existing?.archived ? { archived: true } : {}), ...(existing?.providerData !== undefined ? { providerData: existing.providerData } : {}), ...(existing?.origin !== undefined ? { origin: existing.origin } : {}), @@ -424,6 +428,14 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ : entry)); } + setRead(session: URI, chat: URI, isRead: boolean): Promise { + const chatUri = chat.toString(); + return this._enqueueWrite(session, entries => entries.map(entry => + entry.uri === chatUri + ? { ...entry, isRead } + : entry)); + } + remove(session: URI, chat: URI): Promise { const chatUri = chat.toString(); return this._enqueueWrite(session, entries => entries.filter(entry => entry.uri !== chatUri)); @@ -557,6 +569,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ private _catalogRows(entries: readonly IPersistedPeerChat[]): Array<{ readonly chat: string; readonly order: number; + readonly isRead?: boolean; readonly archived?: boolean; readonly providerData?: string; readonly origin?: string; @@ -565,6 +578,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ return entries.map((entry, order) => ({ chat: entry.uri, order, + ...(entry.isRead !== undefined ? { isRead: entry.isRead } : {}), ...(entry.archived === true ? { archived: true } : {}), ...(entry.providerData !== undefined ? { providerData: entry.providerData } : {}), ...(entry.origin !== undefined ? { origin: this._stringifyOrigin(entry.origin) } : {}), @@ -735,11 +749,15 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ } try { const metadata = await ref.object.getMetadataObject({ + [AH_META_IS_READ_DB_KEY]: true, [CHAT_PROVIDER_DATA_METADATA_KEY]: true, [CHAT_ORIGIN_METADATA_KEY]: true, [CHAT_INHERITED_TURN_METADATA_KEY]: true, [CHAT_WORKING_DIRECTORIES_METADATA_KEY]: true, }); + const isRead = metadata[AH_META_IS_READ_DB_KEY] !== undefined + ? metadata[AH_META_IS_READ_DB_KEY] === 'true' + : entry.isRead; const origin = metadata[CHAT_ORIGIN_METADATA_KEY] ? this._parseOrigin(metadata[CHAT_ORIGIN_METADATA_KEY]) : metadata[CHAT_ORIGIN_METADATA_KEY] === '' ? undefined : entry.origin; @@ -748,6 +766,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ : metadata[CHAT_WORKING_DIRECTORIES_METADATA_KEY] === '' ? undefined : entry.workingDirectories; return { uri: entry.uri, + ...(isRead !== undefined ? { isRead } : {}), ...(metadata[CHAT_PROVIDER_DATA_METADATA_KEY] !== undefined ? metadata[CHAT_PROVIDER_DATA_METADATA_KEY] ? { providerData: metadata[CHAT_PROVIDER_DATA_METADATA_KEY] } : {} : entry.providerData !== undefined ? { providerData: entry.providerData } : {}), @@ -793,6 +812,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ const ref = this._sessionDataService.openDatabase(URI.parse(entry.uri)); try { await ref.object.setMetadataValues({ + ...(entry.isRead !== undefined ? { [AH_META_IS_READ_DB_KEY]: entry.isRead ? 'true' : '' } : {}), [CHAT_PROVIDER_DATA_METADATA_KEY]: entry.providerData ?? '', [CHAT_ORIGIN_METADATA_KEY]: entry.origin === undefined ? '' : this._stringifyOrigin(entry.origin), [CHAT_INHERITED_TURN_METADATA_KEY]: entry.inheritedTurnId ?? '', @@ -826,6 +846,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ private _entriesFromCatalog(chats: readonly { readonly chat: string; + readonly isRead?: boolean; readonly providerData?: string; readonly origin?: string; readonly inheritedTurnId?: string; @@ -833,6 +854,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ }[]): IPersistedPeerChat[] { return chats.map(chat => ({ uri: chat.chat, + ...(chat.isRead !== undefined ? { isRead: chat.isRead } : {}), ...(chat.archived ? { archived: true } : {}), ...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}), ...(chat.origin !== undefined ? { origin: this._parseOrigin(chat.origin) } : {}), @@ -889,6 +911,10 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} with invalid archived state`); continue; } + if (value.isRead !== undefined && typeof value.isRead !== 'boolean') { + this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} with invalid read state`); + continue; + } const originValue = toSerializableJsonValue(value.origin); const origin = fromCatalogChatOrigin(originValue); if (value.origin !== undefined && !origin) { @@ -897,6 +923,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ seen.add(value.uri); result.push({ uri: value.uri, + ...(typeof value.isRead === 'boolean' ? { isRead: value.isRead } : {}), ...(value.archived === true ? { archived: true } : {}), ...(typeof value.providerData === 'string' ? { providerData: value.providerData } : {}), ...(origin ? { origin } : {}), diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 81e894047f79..899607fbabe2 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -13,7 +13,7 @@ import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams, type SessionSummaryChanges } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, isSessionStatusArchived, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, ChatOriginKind, IHostBuildInfo, SessionStatus, ResponsePartKind, ToolCallStatus } from '../common/state/sessionState.js'; +import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isChatInSessionReadAggregate, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, ChatOriginKind, IHostBuildInfo, SessionStatus, ResponsePartKind, ToolCallStatus } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { parseChangesetUri } from '../common/changesetUri.js'; @@ -544,6 +544,7 @@ export class AgentHostStateManager extends Disposable { && chat.title === other.title && equals(chat.origin, other.origin) && chat.interactivity === other.interactivity + && chat.status === other.status && structuralEquals(chat.changes, other.changes); }); } @@ -1074,6 +1075,16 @@ export class AgentHostStateManager extends Disposable { this._summaryNotifier.applyAnnouncedChanges(session, { status }); } + /** Updates passive status metadata and optional exact chat read state for an unloaded session. */ + setSurfacedSessionStatusAndChats(session: string, status: SessionStatus, chats?: SessionSummary['chats']): void { + if (!this._sessionStates.has(session) && this._summaryNotifier.getAnnounced(session)) { + this._summaryNotifier.applyAnnouncedChanges(session, { + status, + ...(chats ? { chats } : {}), + }); + } + } + /** Publishes or unpublishes a live session summary without changing its session state. */ setSessionSummaryPublished(session: string, published: boolean): void { if (published) { @@ -1171,7 +1182,7 @@ export class AgentHostStateManager extends Disposable { * summary (e.g. adoptable-legacy), a `sessionSummaryChanged` delta is emitted * so clients update the entry in place instead of dropping it. */ - restoreSession(summary: SessionSummary, turns: Turn[], options?: { readonly draft?: Message; readonly defaultChatTitle?: string; readonly defaultChatModifiedAt?: string; readonly defaultChatWorkingDirectories?: readonly string[] }): SessionState { + restoreSession(summary: SessionSummary, turns: Turn[], options?: { readonly draft?: Message; readonly defaultChatTitle?: string; readonly defaultChatModifiedAt?: string; readonly defaultChatWorkingDirectories?: readonly string[]; readonly defaultChatIsRead?: boolean }): SessionState { const key = summary.resource; const existing = this._sessionStates.get(key); if (existing) { @@ -1185,7 +1196,7 @@ export class AgentHostStateManager extends Disposable { }; const entry = this._newEntry(state, summary, SessionUse.Used); this._sessionStates.set(key, entry); - this._ensureDefaultChat(key, summary, turns, options?.draft, options?.defaultChatTitle, options?.defaultChatModifiedAt, options?.defaultChatWorkingDirectories); + this._ensureDefaultChat(key, summary, turns, options?.draft, options?.defaultChatTitle, options?.defaultChatModifiedAt, options?.defaultChatWorkingDirectories, options?.defaultChatIsRead); for (const chat of summary.chats ?? []) { if (chat.resource === state.defaultChat || isDefaultChatUri(chat.resource)) { continue; @@ -1194,9 +1205,15 @@ export class AgentHostStateManager extends Disposable { title: chat.title, origin: chat.origin, interactivity: chat.interactivity, + archived: chat.status === undefined ? undefined : isSessionStatusArchived(chat.status), + isRead: chat.status === undefined ? undefined : isSessionStatusRead(chat.status), changes: chat.changes, }); } + const restoredState = entry.state; + if (restoredState.chats.some(chat => isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity) && !isSessionStatusRead(chat.status))) { + restoredState.status = withSessionStatusFlag(restoredState.status, SessionStatus.IsRead, false); + } // A session that was previously surfaced (e.g. announced as an // adoptable-legacy session) is already known to clients with a different // summary. Emit the delta so they update the entry in place — clearing the @@ -1211,7 +1228,7 @@ export class AgentHostStateManager extends Disposable { this._logService.trace(`[AgentHostStateManager] Restored session: ${key} (${turns.length} turns)`); - return state; + return restoredState; } /** @@ -1226,7 +1243,7 @@ export class AgentHostStateManager extends Disposable { * at creation/restore time, so the snapshot a client later receives on * subscribe already reflects the default chat. */ - private _ensureDefaultChat(sessionKey: string, summary: SessionSummary, turns?: Turn[], draft?: Message, defaultChatTitle?: string, modifiedAt?: string, workingDirectories?: readonly string[]): void { + private _ensureDefaultChat(sessionKey: string, summary: SessionSummary, turns?: Turn[], draft?: Message, defaultChatTitle?: string, modifiedAt?: string, workingDirectories?: readonly string[], isRead?: boolean): void { const chatUri = buildDefaultChatUri(sessionKey); const changes = summary.chats?.find(chat => chat.resource === chatUri)?.changes; // Empty title means "inherit the session title"; a persisted independent @@ -1238,6 +1255,9 @@ export class AgentHostStateManager extends Disposable { ...(workingDirectories !== undefined ? { workingDirectories: [...workingDirectories] } : {}), ...(changes !== undefined ? { changes } : {}), }; + if (isRead !== undefined) { + chatSummary.status = withSessionStatusFlag(chatSummary.status, SessionStatus.IsRead, isRead); + } this._chatEntries.set(chatUri, { session: sessionKey, summary: chatSummary, @@ -1297,7 +1317,7 @@ export class AgentHostStateManager extends Disposable { const chatSummary: ChatSummary = { ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), title: options?.title ?? '', - status: SessionStatus.Idle, + status: SessionStatus.Idle | SessionStatus.IsRead, ...(options?.origin ? { origin: options.origin } : {}), interactivity: options?.interactivity, ...(options?.workingDirectories !== undefined ? { workingDirectories: [...options.workingDirectories] } : {}), @@ -1319,7 +1339,7 @@ export class AgentHostStateManager extends Disposable { * creating conversation state. The state-manager-owned resolver installs a * complete state only through {@link resolveChatState}. */ - registerRestoredChatSummary(session: URI, chatUri: URI, options: { readonly title?: string; readonly modifiedAt?: string; readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; readonly archived?: boolean; readonly draft?: Message; readonly providerData?: string; readonly inheritedTurnId?: string; readonly workingDirectories?: readonly string[]; readonly changes?: ChangesSummary; readonly resolver?: RestoredChatResolver }): ChatSummary | undefined { + registerRestoredChatSummary(session: URI, chatUri: URI, options: { readonly title?: string; readonly modifiedAt?: string; readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; readonly archived?: boolean; readonly isRead?: boolean; readonly draft?: Message; readonly providerData?: string; readonly inheritedTurnId?: string; readonly workingDirectories?: readonly string[]; readonly changes?: ChangesSummary; readonly resolver?: RestoredChatResolver }): ChatSummary | undefined { const entry = this._sessionStates.get(session); if (!entry) { this._logService.warn(`[AgentHostStateManager] registerRestoredChatSummary for unknown session: ${session}`); @@ -1330,12 +1350,20 @@ export class AgentHostStateManager extends Disposable { if (existing) { const existingEntry = this._chatEntries.get(chatUri); if (existingEntry && !existingEntry.state && options.resolver) { + let status = existing.status ?? SessionStatus.Idle; + const hasStatusChange = options.archived !== undefined || options.isRead !== undefined; + if (options.archived !== undefined) { + status = withSessionStatusFlag(status, SessionStatus.IsArchived, options.archived); + } + if (options.isRead !== undefined) { + status = withSessionStatusFlag(status, SessionStatus.IsRead, options.isRead); + } const summary: ChatSummary = { ...existing, ...(options.modifiedAt !== undefined ? { modifiedAt: options.modifiedAt } : {}), ...(options.origin !== undefined ? { origin: options.origin } : {}), interactivity: options.interactivity ?? existing.interactivity, - ...(options.archived !== undefined ? { status: withSessionStatusFlag(existing.status ?? SessionStatus.Idle, SessionStatus.IsArchived, options.archived) } : {}), + ...(hasStatusChange ? { status } : {}), ...(options.workingDirectories !== undefined ? { workingDirectories: [...options.workingDirectories] } : {}), ...(existing.changes === undefined && options.changes !== undefined ? { changes: options.changes } : {}), }; @@ -1354,7 +1382,11 @@ export class AgentHostStateManager extends Disposable { ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), title: options.title ?? '', ...(options.modifiedAt !== undefined ? { modifiedAt: options.modifiedAt } : {}), - status: withSessionStatusFlag(SessionStatus.Idle, SessionStatus.IsArchived, options.archived === true), + status: withSessionStatusFlag( + withSessionStatusFlag(SessionStatus.Idle, SessionStatus.IsArchived, options.archived === true), + SessionStatus.IsRead, + options.isRead ?? true, + ), // A persisted catalog entry with no recorded origin is a plain // user-created chat; keep the default rather than restoring it // without provenance. @@ -1909,6 +1941,17 @@ export class AgentHostStateManager extends Disposable { config: preserveProviderBackedRootConfigValues(this._rootState, action.config), }; } + if (action.type === ActionType.SessionIsReadChanged && action.isRead) { + const state = this._sessionStates.get(channel)?.state; + const aggregateChats = state?.chats.filter(chat => isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity)) ?? []; + if (aggregateChats.length > 1 && aggregateChats.some(chat => !isSessionStatusRead(chat.status))) { + action = { ...action, isRead: false }; + } else if (aggregateChats.length === 1 && !isSessionStatusRead(aggregateChats[0].status)) { + this.dispatchServerAction(aggregateChats[0].resource, { type: ActionType.ChatIsReadChanged, isRead: true }); + } + } + let sessionToMarkUnread: URI | undefined; + let singleChatToSynchronize: { readonly chat: URI; readonly isRead: boolean } | undefined; // Apply to state if (isRootAction(action)) { // `RootConfigChanged` can be a true no-op: the reducer merges/replaces @@ -1941,6 +1984,13 @@ export class AgentHostStateManager extends Disposable { const summaryChanged = !this._summaryFieldsEqual(previousState, newState); entry.state = newState; this._synchronizeChatEntries(key, newState.chats); + const aggregateChats = newState.chats.filter(chat => isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity)); + if (sessionAction.type === ActionType.SessionIsReadChanged && !sessionAction.isRead && aggregateChats.length === 1) { + const chat = aggregateChats[0]; + if (isSessionStatusRead(chat.status)) { + singleChatToSynchronize = { chat: chat.resource, isRead: false }; + } + } if (previousState.title !== newState.title) { this._onDidChangeSessionTitle.fire({ session: key, title: newState.title }); @@ -1986,6 +2036,12 @@ export class AgentHostStateManager extends Disposable { this._pruneChatCanvases(channel, new Set(newChat.canvases?.map(canvas => canvas.resource) ?? [])); } this._onChatStateChanged(sessionKey, channel, chat, newChat); + if (chatAction.type === ActionType.ChatIsReadChanged && !chatAction.isRead && isChatInSessionReadAggregate(channel, chatEntry.summary.origin, chatEntry.summary.interactivity)) { + const session = this._sessionStates.get(sessionKey)?.state; + if (session && isSessionStatusRead(session.status)) { + sessionToMarkUnread = sessionKey; + } + } resultingState = newChat; } else { this._logService.warn(`[AgentHostStateManager] Action for unknown chat: ${channel}, type=${action.type}`); @@ -2070,6 +2126,12 @@ export class AgentHostStateManager extends Disposable { this._logService.trace(`[AgentHostStateManager] Emitting envelope: seq=${envelope.serverSeq}, channel=${envelope.channel}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`); this._onDidEmitEnvelope.fire(envelope); + if (singleChatToSynchronize) { + this.dispatchServerAction(singleChatToSynchronize.chat, { type: ActionType.ChatIsReadChanged, isRead: singleChatToSynchronize.isRead }); + } + if (sessionToMarkUnread) { + this.dispatchServerAction(sessionToMarkUnread, { type: ActionType.SessionIsReadChanged, isRead: false }); + } return resultingState; } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index de107a64f48e..a2ac0f310444 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -41,7 +41,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, 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 { 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, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, chatStorageUri, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSessionStatusArchived, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, 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, readSessionGitHubData, parseSessionGitHubData, parseSessionGitHubState, readSessionGitHubStateInput, withMigratedSessionGitHubState, withReplacedFolderGitHubState, SESSION_META_GITHUB_DATA_KEY, withWorkingDirectoryKey, withWorkingDirectoryScopeId, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type SessionSummaryMeta, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.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, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, chatStorageUri, getErrorResponsePart, isAhpChatChannel, isChatInSessionReadAggregate, isChatReadOnly, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, 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, readSessionGitHubData, parseSessionGitHubData, parseSessionGitHubState, readSessionGitHubStateInput, withMigratedSessionGitHubState, withReplacedFolderGitHubState, SESSION_META_GITHUB_DATA_KEY, withWorkingDirectoryKey, withWorkingDirectoryScopeId, type ISessionSourceControlState, 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'; @@ -153,6 +153,7 @@ interface IPassiveSessionMetadataUpdate { readonly key: string; readonly flag: SessionStatus; readonly set: boolean; + readonly requestedSet: boolean; } interface IBackgroundPassiveSessionMetadataWrite { @@ -408,6 +409,7 @@ interface ICatalogChat { readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; readonly archived?: boolean; + readonly isRead?: boolean; readonly inheritedTurnId?: string; readonly workingDirectories?: readonly string[]; readonly changes?: ChangesSummary; @@ -2190,7 +2192,11 @@ export class AgentService extends Disposable implements IAgentService { kind: summary.defaultChat === chat.resource || isDefaultChatUri(chat.resource) ? 'default' : 'peer', origin: chat.origin, ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), - ...(isSessionStatusArchived(chat.status) || chat.archived === true ? { archived: true } : {}), + ...(chat.status !== undefined ? { + archived: isSessionStatusArchived(chat.status), + isRead: isSessionStatusRead(chat.status), + } : {}), + ...(chat.archived === true ? { archived: true } : {}), ...(chat.changes !== undefined ? { changes: chat.changes } : {}), })); } @@ -2367,6 +2373,7 @@ export class AgentService extends Disposable implements IAgentService { uri: buildDefaultChatUri(registered.session), kind: 'default', title: metadata.summary, + isRead: metadata.chats?.find(chat => chat.kind === 'default')?.isRead, ...(defaultChatWorkingDirectories !== undefined ? { workingDirectories: defaultChatWorkingDirectories } : {}), }, ...peers.map(peer => ({ @@ -2374,6 +2381,7 @@ export class AgentService extends Disposable implements IAgentService { kind: 'peer' as const, origin: peer.origin, archived: peer.archived, + isRead: peer.isRead, inheritedTurnId: peer.inheritedTurnId, workingDirectories: peer.workingDirectories, })), @@ -2447,6 +2455,7 @@ export class AgentService extends Disposable implements IAgentService { origin: chat.origin, ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), ...(isSessionStatusArchived(chat.status) && state.defaultChat !== chat.resource && !isDefaultChatUri(chat.resource) ? { archived: true } : {}), + isRead: isSessionStatusRead(chat.status), inheritedTurnId: this._stateManager.getChatInheritedTurnId(chat.resource), workingDirectories: chat.workingDirectories, changes: chat.changes, @@ -3200,12 +3209,14 @@ export class AgentService extends Disposable implements IAgentService { uri: buildDefaultChatUri(metadata.session), kind: 'default', title: metadata.summary, + isRead: metadata.chats?.find(chat => chat.kind === 'default')?.isRead, }, ...peers.map(peer => ({ uri: peer.uri, kind: 'peer' as const, origin: peer.origin, archived: peer.archived, + isRead: peer.isRead, inheritedTurnId: peer.inheritedTurnId, })), ], @@ -3449,6 +3460,7 @@ export class AgentService extends Disposable implements IAgentService { kind: 'peer' as const, origin: peer.origin, archived: peer.archived, + isRead: peer.isRead, inheritedTurnId: peer.inheritedTurnId, workingDirectories: peer.workingDirectories, })), @@ -6843,21 +6855,35 @@ export class AgentService extends Disposable implements IAgentService { * first, so an absent surfaced summary can only mean "unknown". */ private async _applyPassiveSessionMetadata(session: string, action: IIsArchivedChangedAction | IIsReadChangedAction): Promise { - if (!this._stateManager.getSurfacedSessionSummary(session)) { + const summary = this._stateManager.getSurfacedSessionSummary(session); + if (!summary) { return false; } const sessionUri = URI.parse(session); - const [key, flag, set] = action.type === ActionType.SessionIsArchivedChanged + const [key, flag, requestedSet] = action.type === ActionType.SessionIsArchivedChanged ? [AH_META_IS_ARCHIVED_DB_KEY, SessionStatus.IsArchived, action.isArchived] as const : [AH_META_IS_READ_DB_KEY, SessionStatus.IsRead, action.isRead] as const; + const aggregateChats = action.type === ActionType.SessionIsReadChanged + ? summary.chats?.filter(chat => isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity)) ?? [] + : []; + const set = requestedSet && aggregateChats.length > 1 && aggregateChats.some(chat => chat.status !== undefined && !isSessionStatusRead(chat.status)) + ? false + : requestedSet; + const soleChat = aggregateChats.length === 1 ? aggregateChats[0] : undefined; await persistSessionMetadataValues(this._sessionDataService, session, { [key]: set ? 'true' : '', + ...(soleChat && isDefaultChatUri(soleChat.resource) ? { [AH_META_DEFAULT_CHAT_IS_READ_DB_KEY]: set ? 'true' : '' } : {}), ...(action.type === ActionType.SessionIsArchivedChanged && !action.isArchived ? { [AH_META_AUTO_ARCHIVED_AT_DB_KEY]: '' } : {}), }); this._invalidateSessionList(); - this._stateManager.setSurfacedSessionStatusFlag(session, flag, set); + const chats = soleChat + ? summary.chats?.map(chat => chat.resource === soleChat.resource + ? { ...chat, status: withSessionStatusFlag(chat.status ?? summary.status, SessionStatus.IsRead, set) } + : chat) + : undefined; + this._stateManager.setSurfacedSessionStatusAndChats(session, withSessionStatusFlag(summary.status, flag, set), chats); const payloadDirty = this._markCatalogPayloadDirty(session); - this._queuePassiveSessionMetadataSynchronization(sessionUri, { key, flag, set }); + this._queuePassiveSessionMetadataSynchronization(sessionUri, { key, flag, set, requestedSet }); await payloadDirty; return true; } @@ -6947,9 +6973,34 @@ export class AgentService extends Disposable implements IAgentService { let data = request.data; const legacyMetadata = { ...request.legacyMetadata }; for (const update of updates) { + if (update.flag === SessionStatus.IsRead) { + const aggregateChats = data.chats.filter(chat => { + const isToolChat = typeof chat.origin === 'object' + && chat.origin !== null + && !Array.isArray(chat.origin) + && chat.origin.kind === ChatOriginKind.Tool; + return !isToolChat && isChatInSessionReadAggregate(chat.uri, undefined, chat.interactivity); + }); + const set = update.requestedSet && aggregateChats.length > 1 && aggregateChats.some(chat => chat.isRead === false) + ? false + : update.set; + const soleChat = aggregateChats.length === 1 ? aggregateChats[0] : undefined; + data = { + ...data, + isRead: set, + ...(soleChat ? { + chats: data.chats.map(chat => chat.uri === soleChat.uri ? { ...chat, isRead: set } : chat), + } : {}), + }; + legacyMetadata[update.key] = set ? 'true' : ''; + if (soleChat?.kind === 'default') { + legacyMetadata[AH_META_DEFAULT_CHAT_IS_READ_DB_KEY] = set ? 'true' : ''; + } + continue; + } data = { ...data, - ...(update.flag === SessionStatus.IsArchived ? { isArchived: update.set } : { isRead: update.set }), + isArchived: update.set, }; legacyMetadata[update.key] = update.set ? 'true' : ''; } @@ -7481,12 +7532,18 @@ export class AgentService extends Disposable implements IAgentService { private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext: IAgentHostClientTelemetryContext): void { const origin = { clientId, clientSeq }; - const chatState = action.type === ActionType.ChatIsArchivedChanged ? this._stateManager.getChatState(channel) : undefined; + const chatState = action.type === ActionType.ChatIsArchivedChanged || action.type === ActionType.ChatIsReadChanged + ? this._stateManager.getChatState(channel) + : undefined; if (action.type === ActionType.ChatIsArchivedChanged && (isDefaultChatUri(channel) || !chatState || chatState.origin?.kind === ChatOriginKind.Tool || chatState.origin?.kind === ChatOriginKind.SideChat)) { this._stateManager.rejectClientAction(channel, action, origin, 'Only a known independently manageable non-default chat can be archived.'); return; } + if (action.type === ActionType.ChatIsReadChanged && !chatState) { + this._stateManager.rejectClientAction(channel, action, origin, 'Only a known chat can change its read state.'); + return; + } if (action.type === ActionType.SessionIsArchivedChanged && !action.isArchived && this._sessionResidency.isBeingDisposed(sessionChannel)) { this._stateManager.rejectClientAction(channel, action, origin, 'Cannot unarchive a session while it is being deleted.'); return; @@ -8291,6 +8348,7 @@ export class AgentService extends Disposable implements IAgentService { // Check for persisted metadata in the session database let title = meta.summary ?? 'Session'; let isRead: boolean | undefined; + let defaultChatIsRead: boolean | undefined; let isArchived: boolean | undefined; let persistedConfigValues: Record | undefined; let changes: ChangesSummary | undefined; @@ -8308,6 +8366,7 @@ export class AgentService extends Disposable implements IAgentService { const m = await db.object.getMetadataObject({ customTitle: true, [AH_META_IS_READ_DB_KEY]: true, + [AH_META_DEFAULT_CHAT_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, configValues: true, @@ -8335,6 +8394,9 @@ export class AgentService extends Disposable implements IAgentService { if (m[AH_META_IS_READ_DB_KEY] !== undefined) { isRead = m[AH_META_IS_READ_DB_KEY] === 'true'; } + if (m[AH_META_DEFAULT_CHAT_IS_READ_DB_KEY] !== undefined) { + defaultChatIsRead = m[AH_META_DEFAULT_CHAT_IS_READ_DB_KEY] === 'true'; + } const persistedArchived = m[AH_META_IS_ARCHIVED_DB_KEY] ?? m[AH_META_IS_DONE_DB_KEY]; if (persistedArchived !== undefined) { isArchived = persistedArchived === 'true'; @@ -8450,7 +8512,9 @@ export class AgentService extends Disposable implements IAgentService { } if (adoptionListVisible?.isRead !== undefined) { isRead = adoptionListVisible.isRead; + defaultChatIsRead ??= adoptionListVisible.isRead; } + defaultChatIsRead ??= isRead; let turns: readonly Turn[]; try { @@ -8532,6 +8596,7 @@ export class AgentService extends Disposable implements IAgentService { defaultChatTitle, defaultChatModifiedAt, defaultChatWorkingDirectories, + defaultChatIsRead, }); if (adoptionListVisible) { const adoptionMetadata: Record = {}; @@ -8541,6 +8606,7 @@ export class AgentService extends Disposable implements IAgentService { } if (adoptionListVisible.isRead !== undefined) { adoptionMetadata[AH_META_IS_READ_DB_KEY] = adoptionListVisible.isRead ? 'true' : ''; + adoptionMetadata[AH_META_DEFAULT_CHAT_IS_READ_DB_KEY] = adoptionListVisible.isRead ? 'true' : ''; } await this._persistListVisibleSessionState(session, adoptionMetadata); } @@ -8654,8 +8720,9 @@ export class AgentService extends Disposable implements IAgentService { ...(chat.workingDirectories !== undefined ? { workingDirectories: chat.workingDirectories } : {}), }))); } + const entriesWithReadState = entries.map(entry => entry.isRead === undefined ? { ...entry, isRead: true } : entry); const cachedWorkingDirectories = new Map(cached?.filter(chat => chat.kind === 'peer' && chat.workingDirectories !== undefined).map(chat => [chat.uri, chat.workingDirectories])); - const enrichedEntries = entries.map(entry => entry.workingDirectories === undefined && cachedWorkingDirectories.get(entry.uri) !== undefined + const enrichedEntries = entriesWithReadState.map(entry => entry.workingDirectories === undefined && cachedWorkingDirectories.get(entry.uri) !== undefined ? { ...entry, workingDirectories: cachedWorkingDirectories.get(entry.uri) } : entry); const toolChatUris = new Set([ @@ -8739,6 +8806,7 @@ export class AgentService extends Disposable implements IAgentService { if (cached?.some(chat => chat.kind === 'peer')) { const projectedPeers: IPersistedPeerChat[] = cached.filter(chat => chat.kind === 'peer').map(chat => ({ uri: chat.uri, + isRead: true, ...(chat.origin !== undefined ? { origin: chat.origin } : {}), ...(chat.archived === true ? { archived: true } : {}), ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), @@ -8771,6 +8839,7 @@ export class AgentService extends Disposable implements IAgentService { } const entries: IPersistedPeerChat[] = legacy.map(chat => ({ uri: chat.uri.toString(), + isRead: true, ...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}), })); return this._peerChatStore.initialize(session, entries, database); @@ -8806,6 +8875,7 @@ export class AgentService extends Disposable implements IAgentService { inheritedTurnId: entry.inheritedTurnId, workingDirectories: entry.workingDirectories ?? cachedChat?.workingDirectories, archived: entry.archived, + isRead: entry.isRead, changes: cachedChat?.changes, }; })); @@ -8813,7 +8883,7 @@ export class AgentService extends Disposable implements IAgentService { if (!item) { continue; } - const { chatUri, title, draft, providerData, origin, interactivity, inheritedTurnId, workingDirectories, archived, changes } = item; + const { chatUri, title, draft, providerData, origin, interactivity, inheritedTurnId, workingDirectories, archived, isRead, changes } = item; if (this._stateManager.getChatState(chatUri.toString())) { continue; } @@ -8826,6 +8896,7 @@ export class AgentService extends Disposable implements IAgentService { inheritedTurnId, workingDirectories, archived, + isRead, changes, resolver: currentProviderData => this._materializeRestoredPeerChat(session, chatUri, currentProviderData), }); diff --git a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts index dcf54901bdbb..21b517e825dd 100644 --- a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts +++ b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts @@ -9,6 +9,7 @@ import { AdditionalWorktreeLifecycleContribution } from './additionalWorktreeLif import { ChatArchiveContribution } from './chatArchive/chatArchiveContribution.js'; import { ChatDraftContribution } from './chatDraft/chatDraftContribution.js'; import { ChatInputContribution } from './chatInput/chatInputContribution.js'; +import { ChatReadContribution } from './chatRead/chatReadContribution.js'; import { ChatSurfaceContribution } from './chatSurface/chatSurfaceContribution.js'; import { CheckpointAndChangesetContribution } from './checkpointAndChangeset/checkpointAndChangesetContribution.js'; import { ExternalSessionAdoptionContribution } from './externalSessionAdoption/externalSessionAdoptionContribution.js'; @@ -63,5 +64,6 @@ export function registerBuiltInChatContributions( registrations.add(contributions.registerContribution(SessionFlagsContribution)); registrations.add(contributions.registerContribution(AdditionalWorktreeLifecycleContribution)); registrations.add(contributions.registerContribution(ChatArchiveContribution)); + registrations.add(contributions.registerContribution(ChatReadContribution)); return registrations; } diff --git a/src/vs/platform/agentHost/node/chatContributions/chatArchive/chatArchiveContribution.ts b/src/vs/platform/agentHost/node/chatContributions/chatArchive/chatArchiveContribution.ts index 9c00f5dc82f3..d29493e2babc 100644 --- a/src/vs/platform/agentHost/node/chatContributions/chatArchive/chatArchiveContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/chatArchive/chatArchiveContribution.ts @@ -8,7 +8,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { ILogService } from '../../../../log/common/log.js'; import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IDispatchedAction } from '../../../common/agentHostChatContributionsService.js'; import { ActionType } from '../../../common/state/sessionActions.js'; -import { isAhpChatChannel, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../../../common/state/sessionState.js'; +import { isAhpChatChannel, isDefaultChatUri, isSessionStatusRead, parseRequiredSessionUriFromChatUri } from '../../../common/state/sessionState.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { IAgentHostPeerChatPersistenceService } from '../../agentHostPeerChatStore.js'; /** @@ -22,6 +23,7 @@ export class ChatArchiveContribution extends Disposable implements IAgentHostCha constructor( protected readonly _context: IAgentHostChatContributionContext, + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @IAgentHostPeerChatPersistenceService private readonly _peerChatPersistenceService: IAgentHostPeerChatPersistenceService, @ILogService private readonly _logService: ILogService, ) { @@ -37,6 +39,9 @@ export class ChatArchiveContribution extends Disposable implements IAgentHostCha } const session = URI.parse(parseRequiredSessionUriFromChatUri(dispatched.channel)); const chat = URI.parse(dispatched.channel); + if (dispatched.action.isArchived && !isSessionStatusRead(this._stateManager.getChatState(dispatched.channel)?.status)) { + this._stateManager.dispatchServerAction(dispatched.channel, { type: ActionType.ChatIsReadChanged, isRead: true }); + } void this._peerChatPersistenceService.setArchived(session, chat, dispatched.action.isArchived).catch(error => { this._logService.error(error, `[ChatArchiveContribution] Failed to persist archived state for ${dispatched.channel}`); }); diff --git a/src/vs/platform/agentHost/node/chatContributions/chatRead/chatReadContribution.ts b/src/vs/platform/agentHost/node/chatContributions/chatRead/chatReadContribution.ts new file mode 100644 index 000000000000..87ba2b25ca23 --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/chatRead/chatReadContribution.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ILogService } from '../../../../log/common/log.js'; +import { ISessionDataService } from '../../../common/sessionDataService.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IDispatchedAction } from '../../../common/agentHostChatContributionsService.js'; +import { ActionType } from '../../../common/state/sessionActions.js'; +import { AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, isAhpChatChannel, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../../../common/state/sessionState.js'; +import { IAgentHostPeerChatPersistenceService } from '../../agentHostPeerChatStore.js'; +import { persistSessionMetadata } from '../../shared/persistSessionMetadata.js'; + +/** Persists accepted chat read-state changes. */ +export class ChatReadContribution extends Disposable implements IAgentHostChatContribution { + + static readonly id = 'chatRead'; + readonly order = 760; + + constructor( + protected readonly _context: IAgentHostChatContributionContext, + @IAgentHostPeerChatPersistenceService private readonly _peerChatPersistenceService: IAgentHostPeerChatPersistenceService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + onDidDispatchAction(dispatched: IDispatchedAction): void { + if (dispatched.rejectionReason !== undefined + || dispatched.action.type !== ActionType.ChatIsReadChanged + || !isAhpChatChannel(dispatched.channel)) { + return; + } + const sessionResource = parseRequiredSessionUriFromChatUri(dispatched.channel); + if (isDefaultChatUri(dispatched.channel)) { + persistSessionMetadata(this._sessionDataService, this._logService, sessionResource, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, dispatched.action.isRead ? 'true' : ''); + return; + } + const session = URI.parse(sessionResource); + const chat = URI.parse(dispatched.channel); + void this._peerChatPersistenceService.setRead(session, chat, dispatched.action.isRead).catch(error => { + this._logService.error(error, `[ChatReadContribution] Failed to persist read state for ${dispatched.channel}`); + }); + } +} diff --git a/src/vs/platform/agentHost/node/chatContributions/markUnread/markUnreadContribution.ts b/src/vs/platform/agentHost/node/chatContributions/markUnread/markUnreadContribution.ts index a4b05bfce9f5..56a399c93aa1 100644 --- a/src/vs/platform/agentHost/node/chatContributions/markUnread/markUnreadContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/markUnread/markUnreadContribution.ts @@ -5,7 +5,7 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ActionType } from '../../../common/state/sessionActions.js'; -import { SessionStatus } from '../../../common/state/sessionState.js'; +import { isAhpChatChannel, isChatInSessionReadAggregate, SessionStatus } from '../../../common/state/sessionState.js'; import type { IAgentHostChatContribution, IAgentHostChatContributionContext, ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; @@ -29,9 +29,17 @@ export class MarkUnreadContribution extends Disposable implements IAgentHostChat if (turn.reason.kind === 'localCommand' || turn.reason.kind === 'rejected') { return; } - // Route subagent turns to their owning session too (a background subagent - // can complete after the parent turn). Each client keeps its active session - // read; marking it unread is idempotent. + const session = this._stateManager.getSessionState(turn.session); + const chatSummary = isAhpChatChannel(turn.channel) + ? session?.chats.find(chat => chat.resource === turn.channel) + : undefined; + const isKnownChat = !!chatSummary; + if (isKnownChat) { + this._stateManager.dispatchServerAction(turn.channel, { type: ActionType.ChatIsReadChanged, isRead: false }); + } + if (!isChatInSessionReadAggregate(turn.channel, chatSummary?.origin, chatSummary?.interactivity)) { + return; + } const status = this._stateManager.getSessionSummary(turn.session)?.status ?? 0; if (!(status & SessionStatus.IsRead)) { return; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 3a40de686652..613b99a8b291 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -57,7 +57,7 @@ import { type SubscribeResult, type ListSessionsResult, } from '../common/state/sessionProtocol.js'; -import { isAhpAutomationCatalogChannel, isAhpResourceWatchChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; +import { isAhpAutomationCatalogChannel, isAhpResourceWatchChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, withSessionStatusFlag, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; import { IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; @@ -1734,6 +1734,13 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien title: chat.summary ?? '', origin: chat.origin, ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), + ...(chat.isRead !== undefined ? { + status: withSessionStatusFlag( + withSessionStatusFlag(SessionStatus.Idle, SessionStatus.IsArchived, chat.archived === true), + SessionStatus.IsRead, + chat.isRead, + ), + } : {}), ...(chat.archived === true ? { archived: true } : {}), ...(chat.changes !== undefined ? { changes: chat.changes } : {}), })), 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 e04d6c5255e1..78cb76c9e0cc 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -713,7 +713,7 @@ suite('AgentHostProtocolClient', () => { workingDirectories: [URI.file('/home/user/.copilot/chats/quick-1').toString()], chats: [ { resource: 'agent-chat://copilotcli/quick-1/default', title: 'Quick Chat' }, - { resource: 'agent-chat://copilotcli/quick-1/peer', title: 'Peer Chat', archived: true, interactivity: ChatInteractivity.Hidden }, + { resource: 'agent-chat://copilotcli/quick-1/peer', title: 'Peer Chat', status: SessionStatus.Idle | SessionStatus.IsArchived, interactivity: ChatInteractivity.Hidden }, ], defaultChat: 'agent-chat://copilotcli/quick-1/default', _meta: withSessionWorkspaceless(undefined, true), @@ -733,7 +733,7 @@ suite('AgentHostProtocolClient', () => { workingDirectories: [toAgentHostUri(URI.file('/home/user/.copilot/chats/quick-1'), agentHostAuthority('test.example:1234'))], chats: [ { chat: 'agent-chat://copilotcli/quick-1/default', summary: 'Quick Chat', kind: 'default', origin: undefined }, - { chat: 'agent-chat://copilotcli/quick-1/peer', summary: 'Peer Chat', kind: 'peer', origin: undefined, interactivity: ChatInteractivity.Hidden, archived: true }, + { chat: 'agent-chat://copilotcli/quick-1/peer', summary: 'Peer Chat', kind: 'peer', origin: undefined, interactivity: ChatInteractivity.Hidden, archived: true, isRead: false }, ], }]); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts index 16d617f72f2c..71306684611d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts @@ -97,8 +97,8 @@ suite('AgentHostCatalogListReader', () => { [SESSION_META_EHCLI_ADOPTABLE_KEY]: true, }, chats: [ - { uri: `${session.toString()}/chat/default`, order: 0, kind: 'default', summary: 'Catalog title', titleSource: 'user' }, - { uri: `${session.toString()}/chat/peer`, order: 1, kind: 'peer', summary: 'Peer title', titleSource: 'agent', origin: { kind: 'fork', chat: `${session.toString()}/chat/default`, turnId: 'turn-1' }, interactivity: ChatInteractivity.Hidden, archived: true }, + { uri: `${session.toString()}/chat/default`, order: 0, kind: 'default', summary: 'Catalog title', titleSource: 'user', isRead: true }, + { uri: `${session.toString()}/chat/peer`, order: 1, kind: 'peer', summary: 'Peer title', titleSource: 'agent', origin: { kind: 'fork', chat: `${session.toString()}/chat/default`, turnId: 'turn-1' }, interactivity: ChatInteractivity.Hidden, archived: true, isRead: false }, ], }; @@ -191,6 +191,7 @@ suite('AgentHostCatalogListReader', () => { origin: chat.origin, ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), ...(chat.archived === true ? { archived: true } : {}), + ...(chat.isRead !== undefined ? { isRead: chat.isRead } : {}), })), catalogChats: data.chats, }); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts index 65ee1f9c3cbc..cd414b2e077f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts @@ -146,7 +146,7 @@ suite('AgentHostCatalogProjection', () => { ok: true, value: { data: typedData, payload: encoded.payload }, }, - payload: '{"data":{"_meta":{"agentHost/createdBySession":{"chat":"agent-chat://test/parent/default","session":"agent-session://test/parent","turnId":"turn-1"},"agentHost/sessionArtifacts":[{"id":"artifact-1","isArtifact":true,"label":"Catalog payload","link":"https://github.com/microsoft/vscode/pull/1","type":"pullRequest"}],"ehcliAdoptable":true,"ehcliAdopted":true,"git":{"branchName":"feature/catalog","hasGitHubRemote":true,"incomingChanges":2},"github":{"issueUrls":["https://github.com/microsoft/vscode/issues/2"],"owner":"microsoft","pullRequestUrls":["https://github.com/microsoft/vscode/pull/1"],"repo":"vscode"},"multiRoot":{"workspaceFile":"file:///workspace/project.code-workspace"},"vscode.folderPicker":{"hidden":true,"primary":"file:///workspace"},"vscode.sourceControl":{"latestOutcome":"merge","merge":{"commit":"0123456789abcdef"}},"workspaceless":true},"changes":{"additions":12,"deletions":4,"files":2},"chats":[{"kind":"default","order":0,"origin":{"kind":"default","metadata":{"a":1,"b":2}},"summary":"Main","titleSource":"auto","uri":"agent-chat://test/session/default"},{"archived":true,"kind":"peer","order":1,"origin":{"kind":"subagent"},"summary":"Peer","titleSource":"agent","uri":"agent-chat://test/session/peer"}],"isArchived":false,"isChatBacking":false,"isRead":true,"modifiedTime":1720000000000,"project":{"displayName":"workspace","uri":"file:///workspace"},"summary":"Implement opaque catalog payload","titleSource":"user","workingDirectories":["file:///workspace","file:///workspace/secondary"]},"payloadVersion":1}', + payload: '{"data":{"_meta":{"agentHost/createdBySession":{"chat":"agent-chat://test/parent/default","session":"agent-session://test/parent","turnId":"turn-1"},"agentHost/sessionArtifacts":[{"id":"artifact-1","isArtifact":true,"label":"Catalog payload","link":"https://github.com/microsoft/vscode/pull/1","type":"pullRequest"}],"ehcliAdoptable":true,"ehcliAdopted":true,"git":{"branchName":"feature/catalog","hasGitHubRemote":true,"incomingChanges":2},"github":{"issueUrls":["https://github.com/microsoft/vscode/issues/2"],"owner":"microsoft","pullRequestUrls":["https://github.com/microsoft/vscode/pull/1"],"repo":"vscode"},"multiRoot":{"workspaceFile":"file:///workspace/project.code-workspace"},"vscode.folderPicker":{"hidden":true,"primary":"file:///workspace"},"vscode.sourceControl":{"latestOutcome":"merge","merge":{"commit":"0123456789abcdef"}},"workspaceless":true},"changes":{"additions":12,"deletions":4,"files":2},"chats":[{"kind":"default","order":0,"origin":{"kind":"default","metadata":{"a":1,"b":2}},"summary":"Main","titleSource":"auto","uri":"agent-chat://test/session/default"},{"archived":true,"kind":"peer","order":1,"origin":{"kind":"subagent"},"summary":"Peer","titleSource":"agent","uri":"agent-chat://test/session/peer"}],"isArchived":false,"isChatBacking":false,"isRead":true,"modifiedTime":1720000000000,"project":{"displayName":"workspace","uri":"file:///workspace"},"summary":"Implement opaque catalog payload","titleSource":"user","workingDirectories":["file:///workspace","file:///workspace/secondary"]},"payloadVersion":2}', hash: hashAgentHostCatalogPayload(encoded.payload), }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts index d5bfc53df789..81937b115574 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -15,7 +15,7 @@ import { readRemoteSessionOrigin, REMOTE_SESSION_ORIGIN_METADATA_KEY, withRemote import { readSessionInitiator, SESSION_INITIATOR_METADATA_KEY, withSessionInitiator } from '../../common/meta/agentSessionInitiatorMeta.js'; import { parseSessionArtifacts, SessionArtifactType, SESSION_META_ARTIFACTS_KEY, withSessionArtifacts } from '../../common/sessionArtifacts.js'; import { ChatInteractivity, ChatOriginKind } from '../../common/state/protocol/state.js'; -import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionSourceControlOutcome, SessionStatus, withSessionCreationReference, withSessionEhcliAdoptable, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless } from '../../common/state/sessionState.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionSourceControlOutcome, SessionStatus, withSessionCreationReference, withSessionEhcliAdoptable, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless } from '../../common/state/sessionState.js'; import { AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, encodeAgentHostCatalogPayload } from '../../node/agentHostCatalogProjection.js'; import { AgentHostCatalogSourceResolver, CHAT_BACKING_METADATA_KEY, ICatalogSourceState } from '../../node/agentHostCatalogSourceResolver.js'; import { customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; @@ -176,6 +176,68 @@ suite('AgentHostCatalogSourceResolver', () => { }, { interactivity: [ChatInteractivity.Full, ChatInteractivity.ReadOnly], archived: [true, undefined], input: undefined }); }); + test('uses persisted default chat read state only during persistence-first reconciliation', async () => { + const state: ICatalogSourceState = { + ...sourceState(), + chats: [ + { ...sourceState().chats[0], isRead: true }, + { uri: `${chat}/peer`, kind: 'peer', isRead: false }, + ], + }; + const resolver = createResolver({ + [AH_META_IS_READ_DB_KEY]: 'true', + [AH_META_DEFAULT_CHAT_IS_READ_DB_KEY]: '', + }); + + const persisted = await resolver.buildCatalogSyncRequest(session, state, {}, true); + const live = await resolver.buildCatalogSyncRequest(session, state, {}, false); + + assert.deepStrictEqual({ + persistedSession: persisted.data.isRead, + liveSession: live.data.isRead, + persisted: persisted.data.chats.map(entry => entry.isRead), + live: live.data.chats.map(entry => entry.isRead), + }, { + persistedSession: false, + liveSession: false, + persisted: [false, false], + live: [true, false], + }); + }); + + test('excludes unread tool chats from session read reconciliation', async () => { + const state: ICatalogSourceState = { + ...sourceState(), + status: SessionStatus.IsRead, + chats: [ + { ...sourceState().chats[0], isRead: true }, + { + uri: `${chat}/tool`, + kind: 'peer', + isRead: false, + origin: { kind: ChatOriginKind.Tool, chat, toolCallId: 'tool-call' }, + }, + ], + }; + const resolver = createResolver({ + [AH_META_IS_READ_DB_KEY]: 'true', + [AH_META_DEFAULT_CHAT_IS_READ_DB_KEY]: 'true', + }); + + const persisted = await resolver.buildCatalogSyncRequest(session, state, {}, true); + const live = await resolver.buildCatalogSyncRequest(session, state, {}, false); + + assert.deepStrictEqual({ + persistedSession: persisted.data.isRead, + liveSession: live.data.isRead, + chats: live.data.chats.map(entry => entry.isRead), + }, { + persistedSession: true, + liveSession: true, + chats: [true, false], + }); + }); + test('projects remote origins from live and persisted state without losing exact chat or depth', async () => { const live = { session: 'remote-host-copilotcli:/parent', chat: 'remote-host-copilotcli:/parent#peer', depth: 2 }; const persisted = { session: 'agent-host-copilotcli:/origin', chat: 'agent-host-copilotcli:/origin#original', depth: 3 }; diff --git a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index 0687a61ccdea..f4e7b0e5e211 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -14,6 +14,7 @@ import { join } from '../../../../base/common/path.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseSessionV2Envelope } from '../../node/agentHostDatabase.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION } from '../../node/agentHostCatalogProjection.js'; function openDatabase(path: string): Promise { return new Promise((resolve, reject) => { @@ -39,7 +40,7 @@ function close(database: Database): Promise { function createPayload(session: string, sourceRevision: number, isChatBacking = false): string { return stableStringify({ - payloadVersion: 1, + payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, data: { modifiedTime: 100 + sourceRevision, summary: `Title ${sourceRevision}`, @@ -69,7 +70,7 @@ function createEnvelope( session, sessionGeneration, sourceRevision, - payloadVersion: 1, + payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, payloadHash: createHash('sha256').update(payload, 'utf8').digest('hex'), verified: true, payload, @@ -235,7 +236,7 @@ suite('AgentHostDatabase sessions_v2', () => { sessionV2ForeignKeys, sessionChatColumns: sessionChatColumns.map(row => row.name), }, { - version: [{ user_version: 12 }], + version: [{ user_version: 13 }], tables: ['metadata', 'session_chat_catalogs', 'session_chats', 'sessions', 'sessions_v2'], sessionColumns: ['session_uri', 'provider', 'start_time', 'external', 'registration_source', 'modified_time'], sessionV2Columns: [ @@ -243,7 +244,7 @@ suite('AgentHostDatabase sessions_v2', () => { 'source_revision', 'payload_version', 'payload_hash', 'verified', 'payload', 'is_chat_backing', 'modified_time', ], sessionV2ForeignKeys: [], - sessionChatColumns: ['session_uri', 'chat_uri', 'chat_order', 'provider_data', 'origin', 'inherited_turn_id', 'archived'], + sessionChatColumns: ['session_uri', 'chat_uri', 'chat_order', 'provider_data', 'origin', 'inherited_turn_id', 'archived', 'is_read'], }); } finally { @@ -262,7 +263,7 @@ suite('AgentHostDatabase sessions_v2', () => { const before = await database.getSessionChatCatalog(session); const firstResult = await database.replaceSessionChatCatalog(session, [ - { chat: 'ahp-chat://first', order: 0, providerData: 'first', origin: '{"kind":"user"}' }, + { chat: 'ahp-chat://first', order: 0, isRead: false, providerData: 'first', origin: '{"kind":"user"}' }, { chat: 'ahp-chat://second', order: 1, inheritedTurnId: 'turn-1' }, ], undefined); if (firstResult.status !== 'applied') { @@ -302,7 +303,7 @@ suite('AgentHostDatabase sessions_v2', () => { revision: 1, legacyMirroredRevision: 0, chats: [ - { chat: 'ahp-chat://first', order: 0, providerData: 'first', origin: '{"kind":"user"}' }, + { chat: 'ahp-chat://first', order: 0, isRead: false, providerData: 'first', origin: '{"kind":"user"}' }, { chat: 'ahp-chat://second', order: 1, inheritedTurnId: 'turn-1' }, ], }, @@ -534,7 +535,7 @@ suite('AgentHostDatabase sessions_v2', () => { directCurrent: await upgraded.getSessionV2Registration(direct), }, { version, - schemaVersion: [{ user_version: 12 }], + schemaVersion: [{ user_version: 13 }], foreignKeys: [], published: undefined, directLegacy: undefined, @@ -581,7 +582,7 @@ suite('AgentHostDatabase sessions_v2', () => { version: await all(rawDatabase, 'PRAGMA user_version'), tables: (await all(rawDatabase, `SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)).map(row => row.name), }, { - version: [{ user_version: 12 }], + version: [{ user_version: 13 }], tables: ['metadata', 'session_chat_catalogs', 'session_chats', 'sessions', 'sessions_v2'], }); } finally { @@ -674,7 +675,45 @@ suite('AgentHostDatabase sessions_v2', () => { legacyMirroredRevision: 0, chats: [{ chat: 'ahp-chat://peer', order: 0, providerData: 'peer' }], }, - version: [{ user_version: 12 }], + version: [{ user_version: 13 }], + }); + }); + + test('repairs missing chat columns without lowering a future schema version', async () => { + const path = join(temporaryDirectory!, 'agent-host-future-v17.db'); + database = new AgentHostDatabase(path); + await database.registerSessionV2('session://future-v17', { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.replaceSessionChatCatalog('session://future-v17', [ + { chat: 'ahp-chat://peer', order: 0, providerData: 'peer' }, + ], undefined); + await database.close(); + database = undefined; + + const futureDatabase = await openDatabase(path); + await exec(futureDatabase, `ALTER TABLE session_chats DROP COLUMN is_read; + ALTER TABLE session_chats ADD COLUMN parent_chat TEXT; + ALTER TABLE session_chats ADD COLUMN storage_resource TEXT; + PRAGMA user_version = 17`); + await close(futureDatabase); + + database = new AgentHostDatabase(path); + const catalog = await database.getSessionChatCatalog('session://future-v17'); + await database.close(); + database = undefined; + + const repairedDatabase = await openDatabase(path); + const version = await all(repairedDatabase, 'PRAGMA user_version'); + const columns = (await all(repairedDatabase, 'PRAGMA table_info(session_chats)')).map(row => row.name); + await close(repairedDatabase); + + assert.deepStrictEqual({ catalog, version, columns }, { + catalog: { + revision: 1, + legacyMirroredRevision: 0, + chats: [{ chat: 'ahp-chat://peer', order: 0, providerData: 'peer' }], + }, + version: [{ user_version: 17 }], + columns: ['session_uri', 'chat_uri', 'chat_order', 'provider_data', 'origin', 'inherited_turn_id', 'archived', 'parent_chat', 'storage_resource', 'is_read'], }); }); @@ -712,7 +751,7 @@ suite('AgentHostDatabase sessions_v2', () => { external: false, source: 'explicit', }, - version: [{ user_version: 12 }], + version: [{ user_version: 13 }], marker: [{ name: 'future_v6_marker' }], }); }).timeout(10_000); @@ -913,7 +952,7 @@ suite('AgentHostDatabase sessions_v2', () => { /payloadHash must match payload/, ); await assert.rejects( - database.upsertSessionV2(createEnvelope(session, 'generation-1', 3, { payload: '{"payloadVersion":1,"data":{}}' }), 'generation-1'), + database.upsertSessionV2(createEnvelope(session, 'generation-1', 3, { payload: `{"payloadVersion":${AGENT_HOST_CATALOG_PAYLOAD_VERSION},"data":{}}` }), 'generation-1'), /Catalog payload is invalid/, ); await assert.rejects( diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index bd150aca9430..37c36e63fc4b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -10,7 +10,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { ChatOriginKind } from '../../common/state/protocol/state.js'; -import { buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js'; +import { AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js'; import { AGENT_HOST_CATALOG_CHILD_LIMIT } from '../../node/agentHostCatalogProjection.js'; import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentHostPeerChatStore, CHAT_ORIGIN_METADATA_KEY, CHAT_PROVIDER_DATA_METADATA_KEY, PEER_CHATS_METADATA_KEY } from '../../node/agentHostPeerChatStore.js'; @@ -929,6 +929,30 @@ suite('AgentHostPeerChatStore', () => { }); }); + test('persists read state across updates and restores', async () => { + const { store, databaseFor } = createPerResourceStore(); + await store.upsert(session, first, 'old', origin, 'inherited-turn'); + await store.setRead(session, first, false); + await store.upsert(session, first, 'refreshed'); + const unread = await store.tryRead(session); + const centralUnread = (await orchestrator.getSessionChatCatalog(session.toString()))?.chats.find(chat => chat.chat === first.toString())?.isRead; + const compatibilityUnread = await databaseFor(first).getMetadata(AH_META_IS_READ_DB_KEY); + + await store.setRead(session, first, true); + const read = await store.tryRead(session); + const centralRead = (await orchestrator.getSessionChatCatalog(session.toString()))?.chats.find(chat => chat.chat === first.toString())?.isRead; + const compatibilityRead = await databaseFor(first).getMetadata(AH_META_IS_READ_DB_KEY); + + assert.deepStrictEqual({ unread, centralUnread, compatibilityUnread, read, centralRead, compatibilityRead }, { + unread: [{ uri: first.toString(), isRead: false, providerData: 'refreshed', origin, inheritedTurnId: 'inherited-turn' }], + centralUnread: false, + compatibilityUnread: '', + read: [{ uri: first.toString(), isRead: true, providerData: 'refreshed', origin, inheritedTurnId: 'inherited-turn' }], + centralRead: true, + compatibilityRead: 'true', + }); + }); + test('persists and reads the explicit empty sentinel', async () => { const database = new TestSessionDatabase(); const store = createStore(database); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index e28ef1d33218..c4bb837c7e9f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, ChatInputQuestionKind, ChatInputResponseKind, ChatInteractivity, ChatOriginKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, createErrorResponsePart, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; +import { ChangesetStatus, ChatInputQuestionKind, ChatInputResponseKind, ChatInteractivity, ChatOriginKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, createErrorResponsePart, isSessionStatusRead, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { BackgroundWorkKind, type BackgroundShellWork } from '../../common/state/protocol/channels-chat/state.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -232,6 +232,123 @@ suite('AgentHostStateManager', () => { assert.strictEqual(envelopes[0].origin, undefined); }); + test('does not mark a multi-chat session read while any chat is unread', () => { + manager.createSession(makeSessionSummary()); + const peer = buildChatUri(sessionUri, 'peer'); + manager.addChat(sessionUri, peer); + manager.dispatchServerAction(peer, { type: ActionType.ChatIsReadChanged, isRead: false }); + + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true }); + + const state = manager.getSessionState(sessionUri)!; + assert.deepStrictEqual({ + sessionIsRead: !!(state.status & SessionStatus.IsRead), + defaultChatIsRead: !!(manager.getChatState(sessionChatUri)!.status & SessionStatus.IsRead), + peerIsRead: !!(manager.getChatState(peer)!.status & SessionStatus.IsRead), + catalog: state.chats.map(chat => ({ + resource: chat.resource, + isRead: !!(chat.status & SessionStatus.IsRead), + })), + }, { + sessionIsRead: false, + defaultChatIsRead: false, + peerIsRead: false, + catalog: [ + { resource: sessionChatUri, isRead: false }, + { resource: peer, isRead: false }, + ], + }); + }); + + test('marking a chat unread also marks its session unread', () => { + manager.createSession(makeSessionSummary()); + const peer = buildChatUri(sessionUri, 'peer'); + manager.addChat(sessionUri, peer); + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatIsReadChanged, isRead: true }); + manager.dispatchServerAction(peer, { type: ActionType.ChatIsReadChanged, isRead: true }); + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true }); + + manager.dispatchServerAction(peer, { type: ActionType.ChatIsReadChanged, isRead: false }); + + assert.deepStrictEqual({ + sessionIsRead: isSessionStatusRead(manager.getSessionState(sessionUri)!.status), + defaultChatIsRead: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status), + peerIsRead: isSessionStatusRead(manager.getChatState(peer)!.status), + }, { + sessionIsRead: false, + defaultChatIsRead: true, + peerIsRead: false, + }); + }); + + test('tool chats retain exact read state without affecting the session aggregate', () => { + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true }); + const tool = buildChatUri(sessionUri, 'tool'); + manager.addChat(sessionUri, tool, { + origin: { kind: ChatOriginKind.Tool, chat: sessionChatUri, toolCallId: 'tool-call' }, + }); + + manager.dispatchServerAction(tool, { type: ActionType.ChatIsReadChanged, isRead: false }); + const afterToolUnread = { + session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status), + defaultChat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status), + tool: isSessionStatusRead(manager.getChatState(tool)!.status), + }; + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: false }); + const afterSessionUnread = { + session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status), + defaultChat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status), + tool: isSessionStatusRead(manager.getChatState(tool)!.status), + }; + + assert.deepStrictEqual({ afterToolUnread, afterSessionUnread }, { + afterToolUnread: { session: true, defaultChat: true, tool: false }, + afterSessionUnread: { session: false, defaultChat: false, tool: false }, + }); + }); + + test('hidden chats retain exact read state without affecting the session aggregate', () => { + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true }); + const hidden = buildChatUri(sessionUri, 'hidden'); + manager.addChat(sessionUri, hidden, { interactivity: ChatInteractivity.Hidden }); + + manager.dispatchServerAction(hidden, { type: ActionType.ChatIsReadChanged, isRead: false }); + + assert.deepStrictEqual({ + session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status), + defaultChat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status), + hidden: isSessionStatusRead(manager.getChatState(hidden)!.status), + }, { + session: true, + defaultChat: true, + hidden: false, + }); + }); + + test('session read state synchronizes its sole default chat', () => { + manager.createSession(makeSessionSummary()); + + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true }); + const afterRead = { + session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status), + chat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status), + }; + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: false }); + + assert.deepStrictEqual({ + afterRead, + afterUnread: { + session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status), + chat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status), + }, + }, { + afterRead: { session: true, chat: true }, + afterUnread: { session: false, chat: false }, + }); + }); + test('emits session title changes and suppresses no-op assignments', () => { manager.createSession(makeSessionSummary()); @@ -755,6 +872,44 @@ suite('AgentHostStateManager', () => { assert.strictEqual((chatState?.turns[0].responseParts[0] as MarkdownResponsePart).content, 'world'); }); + test('restoreSession marks an inconsistent multi-chat aggregate unread', () => { + const peer = buildChatUri(sessionUri, 'peer'); + const state = manager.restoreSession({ + ...makeSessionSummary(), + status: SessionStatus.Idle | SessionStatus.IsRead, + chats: [{ resource: peer, title: 'Peer', status: SessionStatus.Idle | SessionStatus.IsRead }], + }, [], { defaultChatIsRead: false }); + + assert.deepStrictEqual({ + sessionIsRead: isSessionStatusRead(state.status), + defaultChatIsRead: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status), + peerIsRead: isSessionStatusRead(state.chats.find(chat => chat.resource === peer)!.status), + }, { + sessionIsRead: false, + defaultChatIsRead: false, + peerIsRead: true, + }); + }); + + test('restoreSession excludes hidden unread chats from the session aggregate', () => { + const hidden = buildChatUri(sessionUri, 'hidden'); + const state = manager.restoreSession({ + ...makeSessionSummary(), + status: SessionStatus.Idle | SessionStatus.IsRead, + chats: [{ resource: hidden, title: 'Hidden', interactivity: ChatInteractivity.Hidden, status: SessionStatus.Idle }], + }, [], { defaultChatIsRead: true }); + + assert.deepStrictEqual({ + sessionIsRead: isSessionStatusRead(state.status), + defaultChatIsRead: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status), + hiddenIsRead: isSessionStatusRead(state.chats.find(chat => chat.resource === hidden)!.status), + }, { + sessionIsRead: true, + defaultChatIsRead: true, + hiddenIsRead: false, + }); + }); + test('restoreSession returns existing state for duplicate session', () => { const existing = manager.createSession(makeSessionSummary()); @@ -933,6 +1088,42 @@ suite('AgentHostStateManager', () => { }); }); + test('emits sessionSummaryChanged when compact chat status changes', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const chat = buildChatUri(sessionUri, 'compact-status'); + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady }); + manager.addChat(sessionUri, chat, { title: 'Peer' }); + await new Promise(r => setTimeout(r, 150)); + + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(notification => notifications.push(notification))); + manager.dispatchServerAction(chat, { type: ActionType.ChatIsArchivedChanged, isArchived: true }); + await new Promise(r => setTimeout(r, 150)); + + const changes = notifications + .filter(notification => notification.type === NotificationType.SessionSummaryChanged) + .map(notification => notification.changes); + assert.deepStrictEqual(changes, [{ + chats: [ + { + resource: buildDefaultChatUri(sessionUri), + title: 'Test', + origin: { kind: MessageKind.User }, + status: SessionStatus.Idle, + }, + { + resource: chat, + title: 'Peer', + origin: { kind: MessageKind.User }, + status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived, + archived: true, + }, + ], + }]); + }); + }); + test('coalesces multiple summary changes into one notification', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { manager.createSession(makeSessionSummary()); @@ -1249,7 +1440,7 @@ suite('AgentHostStateManager', () => { chatResources: [buildDefaultChatUri(sessionUri), peerChat].sort(), summaryChats: [ { resource: buildDefaultChatUri(sessionUri), title: 'Test', origin: { kind: MessageKind.User }, status: SessionStatus.Idle }, - { resource: peerChat, title: 'Peer', origin: { kind: MessageKind.User }, status: SessionStatus.Idle }, + { resource: peerChat, title: 'Peer', origin: { kind: MessageKind.User }, status: SessionStatus.Idle | SessionStatus.IsRead }, ], defaultChat: buildDefaultChatUri(sessionUri), peerTurns: 0, @@ -1569,14 +1760,14 @@ suite('AgentHostStateManager', () => { resource: peerChat, title: 'Peer', origin: { kind: ChatOriginKind.User }, - status: SessionStatus.Idle | SessionStatus.IsArchived, + status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived, archived: true, }, sessionArchived: false, action: { type: ActionType.SessionChatUpdated, chat: peerChat, - changes: { status: SessionStatus.Idle | SessionStatus.IsArchived, activity: undefined }, + changes: { status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived, activity: undefined }, }, }); }); @@ -2184,7 +2375,7 @@ suite('AgentHostStateManager', () => { ...makeSessionSummary(), chats: [ { resource: defaultChat, title: '' }, - { resource: peerChat, title: 'Peer', status: SessionStatus.Idle }, + { resource: peerChat, title: 'Peer', status: SessionStatus.Idle | SessionStatus.IsRead }, ], defaultChat, }; @@ -2197,7 +2388,7 @@ suite('AgentHostStateManager', () => { assert.strictEqual( manager.getSessionState(sessionUri)?.chats.find(chat => chat.resource === peerChat)?.status, - SessionStatus.Idle | SessionStatus.IsArchived, + SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived, ); }); @@ -2410,9 +2601,10 @@ suite('AgentHostStateManager', () => { resource: chat.resource, title: chat.title, interactivity: chat.interactivity, + status: chat.status, })), [ - { resource: sessionChatUri, title: 'Test', interactivity: undefined }, - { resource: peerChat, title: 'Hidden peer', interactivity: ChatInteractivity.Hidden }, + { resource: sessionChatUri, title: 'Test', interactivity: undefined, status: SessionStatus.Idle }, + { resource: peerChat, title: 'Hidden peer', interactivity: ChatInteractivity.Hidden, status: SessionStatus.Idle | SessionStatus.IsRead }, ]); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index fa9291d4555a..45a5a2078210 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -292,6 +292,7 @@ suite('AgentSideEffects — tool call telemetry', () => { [IAgentHostClientConnectionService, clientConnectionService], [IAgentHostPeerChatPersistenceService, { _serviceBrand: undefined, + setRead: async () => { }, setArchived: async () => { }, }], [ISessionWorkspaceConversionService, { diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index 726c901a1204..600b91168efb 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -249,6 +249,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { [IAgentHostClientConnectionService, clientConnections], [IAgentHostPeerChatPersistenceService, { _serviceBrand: undefined, + setRead: async () => { }, setArchived: async () => { }, }], [ISessionWorkspaceConversionService, { diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 4b05505977da..24c6ed8499c2 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -286,6 +286,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], [IAgentHostPeerChatPersistenceService, { _serviceBrand: undefined, + setRead: async () => { }, setArchived: async () => { }, }], [ISessionWorkspaceConversionService, { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 5276e8bb64c4..ac54390676d1 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -53,7 +53,7 @@ import { buildSandboxConfigForSdk } from '../../node/copilot/sandboxConfigForSdk import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType, type INotification, type SessionSummaryChanges } from '../../common/state/sessionActions.js'; -import { AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_CREATED_BY_SESSION_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, isDefaultChatUri, isMessageRequestHiddenFromTranscript, isSessionStatusArchived, 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 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, isDefaultChatUri, 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 ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { BackgroundWorkKind, ChatInteractivity, PendingMessageKind, type BackgroundWork, type Message, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; @@ -3759,6 +3759,65 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('applies independent read actions to the default chat only', async () => { + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: 'copilot' }); + const defaultChat = buildDefaultChatUri(session.toString()); + const peerChat = buildChatUri(session, 'peer'); + getStateManager(svc).addChat(session.toString(), peerChat); + getStateManager(svc).dispatchServerAction(peerChat, { type: ActionType.ChatIsReadChanged, isRead: false }); + const envelopePromise = Event.toPromise(Event.filter(svc.onDidAction, envelope => envelope.origin?.clientSeq === 1)); + + svc.dispatchAction(defaultChat, { + type: ActionType.ChatIsReadChanged, + isRead: true, + }, 'test-client', 1); + const envelope = await envelopePromise; + const metadata = (await svc.listSessions()).find(candidate => candidate.session.toString() === session.toString()); + + assert.deepStrictEqual({ + rejectionReason: envelope.rejectionReason, + chatRead: ((getStateManager(svc).getChatState(defaultChat)?.status ?? 0) & SessionStatus.IsRead) !== 0, + sessionRead: ((getStateManager(svc).getSessionState(session.toString())?.status ?? 0) & SessionStatus.IsRead) !== 0, + listChatReadState: metadata?.chats?.map(chat => ({ kind: chat.kind, isRead: chat.isRead })), + }, { + rejectionReason: undefined, + chatRead: true, + sessionRead: false, + listChatReadState: [ + { kind: 'default', isRead: true }, + { kind: 'peer', isRead: false }, + ], + }); + }); + + test('rejects independent read actions for an unknown chat', async () => { + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: 'copilot' }); + const unknownChat = buildChatUri(session, 'unknown'); + const envelopePromise = Event.toPromise(Event.filter(svc.onDidAction, envelope => envelope.origin?.clientSeq === 1)); + + svc.dispatchAction(unknownChat, { + type: ActionType.ChatIsReadChanged, + isRead: true, + }, 'test-client', 1); + const envelope = await envelopePromise; + + assert.deepStrictEqual({ + rejectionReason: envelope.rejectionReason, + chat: getStateManager(svc).getChatState(unknownChat), + }, { + rejectionReason: 'Only a known chat can change its read state.', + chat: undefined, + }); + }); + test('rejects independent archive actions for tool chats', async () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); @@ -17087,6 +17146,7 @@ suite('AgentService (node dispatcher)', () => { persistedTitle: await onDatabase.getMetadata(SESSION_CUSTOM_TITLE_KEY), persistedTitleSource: await onDatabase.getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), persistedIsRead: await onDatabase.getMetadata(AH_META_IS_READ_DB_KEY), + persistedDefaultChatIsRead: await onDatabase.getMetadata(AH_META_DEFAULT_CHAT_IS_READ_DB_KEY), hasCatalogSyncSnapshot: await onDatabase.getCatalogSyncSnapshot() !== undefined, }, { @@ -17097,6 +17157,7 @@ suite('AgentService (node dispatcher)', () => { persistedTitle: 'Adopted Legacy Title', persistedTitleSource: 'user', persistedIsRead: 'true', + persistedDefaultChatIsRead: 'true', hasCatalogSyncSnapshot: true, }, ); @@ -17466,6 +17527,73 @@ suite('AgentService (node dispatcher)', () => { } }); + test('a passive read toggle synchronizes a sole default chat and clamps an unread normal peer', async () => { + for (const { id, chats, expectedSessionRead, expectedDefaultRead } of [ + { + id: 'single', + chats: [{ resource: '', title: '', status: SessionStatus.Idle }], + expectedSessionRead: true, + expectedDefaultRead: true, + }, + { + id: 'multi', + chats: [ + { resource: '', title: '', status: SessionStatus.Idle | SessionStatus.IsRead }, + { resource: 'peer', title: 'Peer', status: SessionStatus.Idle }, + ], + expectedSessionRead: false, + expectedDefaultRead: true, + }, + ]) { + const db = new TestSessionDatabase(); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + registerTestAgentProvider(localService, disposables.add(new MockAgent('copilot'))); + const session = AgentSession.uri('copilot', `passive-read-${id}`); + const sessionStr = session.toString(); + const summary = { + resource: sessionStr, + provider: 'copilot', + title: 'Idle', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + chats: chats.map((chat, index) => ({ + ...chat, + resource: index === 0 ? buildDefaultChatUri(sessionStr) : buildChatUri(sessionStr, chat.resource), + })), + }; + getStateManager(localService).announceSurfacedSession(summary); + getStateManager(localService).prepareSessionSummariesForListing([summary]); + const notifications: INotification[] = []; + const listener = localService.onDidNotification(notification => notifications.push(notification)); + + localService.dispatchAction(sessionStr, { type: ActionType.SessionIsReadChanged, isRead: true }, 'test-client', 1, AgentHostClientType.EditorWindow); + for (let attempt = 0; attempt < 20 && !notifications.some(notification => notification.type === 'root/sessionSummaryChanged'); attempt++) { + await timeout(0); + } + listener.dispose(); + + const changed = notifications.find(notification => notification.type === 'root/sessionSummaryChanged'); + assert.deepStrictEqual({ + id, + persistedSession: await db.getMetadata(AH_META_IS_READ_DB_KEY), + persistedDefault: await db.getMetadata(AH_META_DEFAULT_CHAT_IS_READ_DB_KEY), + publishedSessionRead: changed?.type === 'root/sessionSummaryChanged' + ? !!(changed.changes.status! & SessionStatus.IsRead) + : undefined, + publishedChats: changed?.type === 'root/sessionSummaryChanged' + ? changed.changes.chats?.map(chat => chat.status === undefined ? undefined : isSessionStatusRead(chat.status)) + : undefined, + }, { + id, + persistedSession: expectedSessionRead ? 'true' : '', + persistedDefault: id === 'single' && expectedDefaultRead ? 'true' : undefined, + publishedSessionRead: expectedSessionRead, + publishedChats: id === 'single' ? [expectedDefaultRead] : undefined, + }); + } + }); + test('passive metadata publishes before central catalog synchronization completes', async () => { const db = new TestSessionDatabase(); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); @@ -20791,6 +20919,8 @@ suite('AgentService (node dispatcher)', () => { const other = URI.parse(buildChatUri(parent, otherId)); await localService.createChat(parent, selected, { title: 'Original Selected Chat' }); await localService.createChat(parent, other, { title: 'Original Other Chat' }); + const peerStore = new AgentHostPeerChatStore(database, sessionData, new NullLogService()); + await peerStore.setRead(parent, other, false); await perSession.database(other).createTurn('original-turn'); await perSession.database(other).setTurnEventId('original-turn', 'original-event'); const phantom = parent.with({ fragment: selectedId }); @@ -20798,7 +20928,6 @@ suite('AgentService (node dispatcher)', () => { await database.registerRuntimeSession(phantom.toString(), { provider: 'copilotcli', startTime: 1, source: 'restore' }, { checkTombstone: false }); await database.registerRuntimeSession(explicitFragment.toString(), { provider: 'copilotcli', startTime: 1, source: 'explicit' }, { checkTombstone: false }); await perSession.database(parent).setMetadata('peerChats', '[]'); - const peerStore = new AgentHostPeerChatStore(database, sessionData, new NullLogService()); await peerStore.reconcileLegacy(parent); if (completed) { await peerStore.recoverChatSelectionCorruption(parent, [selectedId]); @@ -20827,7 +20956,7 @@ suite('AgentService (node dispatcher)', () => { stateManager.removeSession(parent.toString()); } - const membershipBeforeListing = completed ? await database.getSessionChatCatalog(parent.toString()) : undefined; + const membershipBeforeListing = completed ? (await database.getSessionChatCatalog(parent.toString()))?.chats.map(chat => chat.chat) : undefined; const recoveryBeforeListing = completed ? await perSession.database(parent).getMetadata('agentHost.peerChatRecovery339409') : undefined; const listed = await localService.listSessions(); const coldParentUnopened = !cold || stateManager.getSessionState(parent.toString()) === undefined; @@ -20844,14 +20973,16 @@ suite('AgentService (node dispatcher)', () => { registered: registered.map(resource => resource.toString()).sort(), membership: (await database.getSessionChatCatalog(parent.toString()))?.chats.map(chat => chat.chat), listed: listed.find(entry => entry.session.toString() === parent.toString())?.chats?.map(chat => ({ uri: chat.chat.toString(), title: chat.summary ?? '' })), + listedRecoveredPeerIsRead: listed.find(entry => entry.session.toString() === parent.toString())?.chats?.find(chat => chat.chat.toString() === other.toString())?.isRead, turnEvent: await perSession.database(other).getTurnEventId('original-turn'), providerData: await perSession.database(other).getMetadata('agentHost.chatProviderData'), lazyBeforeOpen, coldParentUnopened, materializedBeforeOpen, projectedAfterFirstList, + projectedRecoveredPeerIsRead: catalogDataOf(await database.getSessionV2(parent.toString()))?.chats.find(chat => chat.uri === other.toString())?.isRead, completedRecoveryUnchanged: !completed || recoveryBeforeListing === await perSession.database(parent).getMetadata('agentHost.peerChatRecovery339409'), - completedMembershipUnchanged: !completed || JSON.stringify(membershipBeforeListing) === JSON.stringify(await database.getSessionChatCatalog(parent.toString())), + completedMembershipUnchanged: !completed || JSON.stringify(membershipBeforeListing) === JSON.stringify((await database.getSessionChatCatalog(parent.toString()))?.chats.map(chat => chat.chat)), restoredTurns: restored?.turns.map(turn => ({ id: turn.id, text: turn.message.text })), materialized: agent.materialized, }, { @@ -20862,12 +20993,14 @@ suite('AgentService (node dispatcher)', () => { { uri: selected.toString(), title: 'Original Selected Chat' }, { uri: other.toString(), title: 'Original Other Chat' }, ], + listedRecoveredPeerIsRead: false, turnEvent: 'original-event', providerData: '{"sdkSessionId":"original-sdk-session"}', lazyBeforeOpen: true, coldParentUnopened: true, materializedBeforeOpen: [], projectedAfterFirstList: [buildDefaultChatUri(parent), selected.toString(), other.toString()], + projectedRecoveredPeerIsRead: false, completedRecoveryUnchanged: true, completedMembershipUnchanged: true, restoredTurns: [{ id: 'original-turn', text: 'Original peer conversation' }], @@ -21172,12 +21305,12 @@ suite('AgentService (node dispatcher)', () => { { uri: peer.toString(), title: 'Lazy Central Peer' }, ], beforeAccess: { - peerCatalogReads: 1, + peerCatalogReads: 2, legacyEnumerations: 0, peerMaterializations: 0, }, afterAccess: { - peerCatalogReads: 1, + peerCatalogReads: 2, legacyEnumerations: 0, peerMaterializations: 1, }, @@ -21699,6 +21832,58 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('restores an unread default chat with an unread session aggregate', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + const sessionUri = session.toString(); + const defaultChat = buildDefaultChatUri(session); + + getStateManager(localService).dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true }); + localService.dispatchAction(defaultChat, { type: ActionType.ChatIsReadChanged, isRead: true }, 'test-client', 1); + localService.dispatchAction(defaultChat, { type: ActionType.ChatIsReadChanged, isRead: false }, 'test-client', 2); + await waitForMetadata(db, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, ''); + + getStateManager(localService).deleteSession(sessionUri); + await localService.restoreSession(session); + + assert.deepStrictEqual({ + sessionIsRead: !!(getStateManager(localService).getSessionState(sessionUri)!.status & SessionStatus.IsRead), + defaultChatIsRead: !!(getStateManager(localService).getChatState(defaultChat)!.status & SessionStatus.IsRead), + persistedDefaultChatIsRead: await db.getMetadata(AH_META_DEFAULT_CHAT_IS_READ_DB_KEY), + }, { + sessionIsRead: false, + defaultChatIsRead: false, + persistedDefaultChatIsRead: '', + }); + }); + + test('restores a legacy read session with its default chat read', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + const sessionUri = session.toString(); + const defaultChat = buildDefaultChatUri(session); + await db.setMetadata(AH_META_IS_READ_DB_KEY, 'true'); + + getStateManager(localService).deleteSession(sessionUri); + await localService.restoreSession(session); + + assert.deepStrictEqual({ + sessionIsRead: !!(getStateManager(localService).getSessionState(sessionUri)!.status & SessionStatus.IsRead), + defaultChatIsRead: !!(getStateManager(localService).getChatState(defaultChat)!.status & SessionStatus.IsRead), + persistedDefaultChatIsRead: await db.getMetadata(AH_META_DEFAULT_CHAT_IS_READ_DB_KEY), + }, { + sessionIsRead: true, + defaultChatIsRead: true, + persistedDefaultChatIsRead: undefined, + }); + }); + test('a session rename retitles the sole default chat in the live session and across restore', async () => { // Separate databases per channel, matching production: the chat-local title // writes must be observable independently of the session's own metadata. @@ -21804,9 +21989,14 @@ suite('AgentService (node dispatcher)', () => { chatIds: (state?.chats ?? []).map(chat => parseChatUri(chat.resource)?.chatId), summary: (() => { const summary = state?.chats.find(chat => chat.resource.toString() === peerUri.toString()); - return summary && { title: summary.title, origin: summary.origin }; + return summary && { + title: summary.title, + origin: summary.origin, + isRead: (summary.status & SessionStatus.IsRead) !== 0, + }; })(), chatState: getStateManager(localService).getChatState(peerUri.toString()), + persistedRead: (JSON.parse(await db.getMetadata('peerChats') ?? '[]') as IPersistedPeerChat[]).find(chat => chat.uri === peerUri.toString())?.isRead, }; await localService.subscribe(peerUri, 'first-peer-reader'); const hydrated = getStateManager(localService).getChatState(peerUri.toString()); @@ -21827,8 +22017,10 @@ suite('AgentService (node dispatcher)', () => { summary: { title: 'Persisted Peer Title', origin: peerOrigin, + isRead: true, }, chatState: undefined, + persistedRead: true, }, firstAccessCalls: [ { call: 'materialize', uri: peerUri.toString(), providerData: 'blob-1' }, @@ -23088,7 +23280,7 @@ suite('AgentService (node dispatcher)', () => { catalog, restoredChats: getStateManager(localService).getSessionState(session.toString())?.chats.map(chat => chat.resource), }, { - catalog: [{ uri: peer.toString(), providerData: 'new-peer-backing' }], + catalog: [{ uri: peer.toString(), isRead: true, providerData: 'new-peer-backing' }], restoredChats: [buildDefaultChatUri(session), peer.toString()], }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 93953a227133..f3feabf2e7fd 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -237,6 +237,7 @@ function createTestSideEffects( [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], [IAgentHostPeerChatPersistenceService, { _serviceBrand: undefined, + setRead: async () => { }, setArchived: async () => { }, }], ); @@ -3458,7 +3459,7 @@ suite('AgentSideEffects', () => { ]); }); - test('marks the parent session unread when a subagent turn completes', () => { + test('does not mark the parent session unread when a subagent turn completes', () => { const { sideEffects: persisting } = setupPersisting(); setupSession(); // The session has been read (e.g. a client viewed it after the parent @@ -3496,8 +3497,8 @@ suite('AgentSideEffects', () => { readChanges: readChangesFrom(envelopes), isReadBitSet: (stateManager.getSessionSummary(sessionUri.toString())!.status & SessionStatus.IsRead) !== 0, }, { - readChanges: [false], - isReadBitSet: false, + readChanges: [], + isReadBitSet: true, }); }); test('marks a read session unread when a turn is cancelled', () => { @@ -3687,10 +3688,10 @@ suite('AgentSideEffects', () => { phase: { ...metadata.fusionPhase, status: 'cancelled', duration: phase.duration }, finalizedBeforeAbort: true, abortCalls: 1, - actions: [status === ToolCallStatus.Streaming ? ActionType.ChatToolCallDelta : ActionType.ChatToolCallReady, ActionType.ChatTurnCancelled, ActionType.ChatToolCallComplete], - liveTurns: [turn], + actions: [status === ToolCallStatus.Streaming ? ActionType.ChatToolCallDelta : ActionType.ChatToolCallReady, ActionType.ChatTurnCancelled, ActionType.ChatIsReadChanged, ActionType.ChatToolCallComplete], + liveTurns: [structuredClone(turn)], liveActiveTurn: undefined, - restoredTurns: [turn], + restoredTurns: [structuredClone(turn)], restoredActiveTurn: undefined, pending: [], toolEvents: [], diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 7f5cf855825d..d8a6145acde4 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -35,7 +35,7 @@ import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ChatOriginKind, MessageAttachmentKind } from '../../common/state/protocol/state.js'; -import { AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatInteractivity, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, TurnState, withSessionExternal, type ISessionGitHubState, type Message, type PendingMessage, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatInteractivity, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, TurnState, withSessionExternal, type ISessionGitHubState, type Message, type PendingMessage, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; @@ -57,7 +57,9 @@ import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry, IAgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AdditionalWorktreeLifecycleService, IAdditionalWorktreeLifecycleService } from '../../node/chatContributions/additionalWorktreeLifecycle/additionalWorktreeLifecycleService.js'; import { ChatArchiveContribution } from '../../node/chatContributions/chatArchive/chatArchiveContribution.js'; +import { ChatReadContribution } from '../../node/chatContributions/chatRead/chatReadContribution.js'; import { LocalCommandContribution } from '../../node/chatContributions/localCommand/localCommandContribution.js'; +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 { SessionWorkspaceConversionContribution } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.js'; @@ -902,6 +904,7 @@ function createBuiltInContributions(disposables: ReturnType { }, setArchived: async () => { }, }], ); @@ -1118,6 +1121,7 @@ suite('AgentHostChatContributions', () => { }; const peerChatPersistenceService: IAgentHostPeerChatPersistenceService = { _serviceBrand: undefined, + setRead: async () => { }, setArchived: async (session: URI, chat: URI, archived: boolean) => { if (chat.toString() === failingChat) { throw new Error('write failed'); @@ -1125,8 +1129,22 @@ suite('AgentHostChatContributions', () => { persisted.push({ session: session.toString(), chat: chat.toString(), archived }); }, }; + const stateManager = disposables.add(new AgentHostStateManager(logService)); + stateManager.createSession({ + resource: session, + provider: 'test', + title: 'Archive', + status: SessionStatus.IsRead, + createdAt: '2025-01-01T00:00:00.000Z', + modifiedAt: '2025-01-01T00:00:00.000Z', + }); + stateManager.addChat(session, peerChat); + stateManager.addChat(session, failingChat); + stateManager.dispatchServerAction(peerChat, { type: ActionType.ChatIsReadChanged, isRead: false }); + stateManager.dispatchServerAction(failingChat, { type: ActionType.ChatIsReadChanged, isRead: false }); const services = new ServiceCollection( [ILogService, logService], + [IAgentHostStateManager, stateManager], [IAgentHostPeerChatPersistenceService, peerChatPersistenceService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); @@ -1143,12 +1161,160 @@ suite('AgentHostChatContributions', () => { assert.deepStrictEqual({ persisted, errors, + peerRead: !!((stateManager.getChatState(peerChat)?.status ?? 0) & SessionStatus.IsRead), + failingRead: !!((stateManager.getChatState(failingChat)?.status ?? 0) & SessionStatus.IsRead), }, { persisted: [ { session, chat: peerChat, archived: true }, { session, chat: peerChat, archived: false }, ], errors: [`Error: write failed [ChatArchiveContribution] Failed to persist archived state for ${failingChat}`], + peerRead: true, + failingRead: true, + }); + }); + + test('mark unread contribution updates each completed chat while the session is already unread', () => { + const session = 'agent-host-session://unread'; + const firstPeer = buildChatUri(session, 'first-peer'); + const secondPeer = buildChatUri(session, 'second-peer'); + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + stateManager.createSession({ + resource: session, + provider: 'test', + title: 'Unread', + status: SessionStatus.IsRead, + createdAt: '2025-01-01T00:00:00.000Z', + modifiedAt: '2025-01-01T00:00:00.000Z', + }); + stateManager.addChat(session, firstPeer); + stateManager.addChat(session, secondPeer); + const chatReadChanges: { chat: string; isRead: boolean }[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.ChatIsReadChanged) { + chatReadChanges.push({ chat: envelope.channel, isRead: envelope.action.isRead }); + } + })); + const services = new ServiceCollection( + [ILogService, logService], + [IAgentHostStateManager, stateManager], + ); + const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); + const contributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); + disposables.add(contributions.registerContribution(MarkUnreadContribution as unknown as IConstructorSignature & { readonly id: string })); + + contributions.turnEnd({ session, channel: firstPeer, turnId: 'first', reason: { kind: 'success' } }); + contributions.turnEnd({ session, channel: secondPeer, turnId: 'second', reason: { kind: 'success' } }); + contributions.turnEnd({ session, channel: buildDefaultChatUri(session), turnId: 'default', reason: { kind: 'success' } }); + contributions.turnEnd({ session, channel: firstPeer, turnId: undefined, reason: { kind: 'rejected', error: { errorType: 'requestFailed', message: 'rejected' } } }); + + assert.deepStrictEqual({ + chatReadChanges, + defaultChatIsRead: !!(stateManager.getChatState(buildDefaultChatUri(session))!.status & SessionStatus.IsRead), + firstPeerIsRead: !!(stateManager.getChatState(firstPeer)!.status & SessionStatus.IsRead), + secondPeerIsRead: !!(stateManager.getChatState(secondPeer)!.status & SessionStatus.IsRead), + sessionIsRead: !!(stateManager.getSessionState(session)!.status & SessionStatus.IsRead), + }, { + chatReadChanges: [ + { chat: firstPeer, isRead: false }, + { chat: secondPeer, isRead: false }, + { chat: buildDefaultChatUri(session), isRead: false }, + ], + defaultChatIsRead: false, + firstPeerIsRead: false, + secondPeerIsRead: false, + sessionIsRead: false, + }); + }); + + test('mark unread contribution leaves the parent read when tool and hidden chats complete', () => { + const session = 'agent-host-session://tool-unread'; + const toolChat = buildChatUri(session, 'tool'); + const hiddenChat = buildChatUri(session, 'hidden'); + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + stateManager.createSession({ + resource: session, + provider: 'test', + title: 'Tool unread', + status: SessionStatus.IsRead, + createdAt: '2025-01-01T00:00:00.000Z', + modifiedAt: '2025-01-01T00:00:00.000Z', + }); + stateManager.addChat(session, toolChat, { + origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(session), toolCallId: 'tool-call' }, + }); + stateManager.addChat(session, hiddenChat, { + interactivity: ChatInteractivity.Hidden, + }); + const services = new ServiceCollection( + [ILogService, logService], + [IAgentHostStateManager, stateManager], + ); + const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); + const contributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); + disposables.add(contributions.registerContribution(MarkUnreadContribution as unknown as IConstructorSignature & { readonly id: string })); + + contributions.turnEnd({ session, channel: toolChat, turnId: 'tool-turn', reason: { kind: 'success' } }); + contributions.turnEnd({ session, channel: hiddenChat, turnId: 'hidden-turn', reason: { kind: 'success' } }); + + assert.deepStrictEqual({ + sessionIsRead: !!(stateManager.getSessionState(session)!.status & SessionStatus.IsRead), + toolChatIsRead: !!(stateManager.getChatState(toolChat)!.status & SessionStatus.IsRead), + hiddenChatIsRead: !!(stateManager.getChatState(hiddenChat)!.status & SessionStatus.IsRead), + }, { + sessionIsRead: true, + toolChatIsRead: false, + hiddenChatIsRead: false, + }); + }); + + test('chat read contribution persists accepted default and peer chat actions and logs failures', async () => { + const session = 'agent-host-session://read'; + const peerChat = buildChatUri(session, 'peer'); + const failingChat = buildChatUri(session, 'failing-peer'); + const persisted: { session: string; chat: string; isRead: boolean }[] = []; + const errors: string[] = []; + const logService = new class extends NullLogService { + override error(message: string | Error, ...args: unknown[]): void { + errors.push([message, ...args].map(value => String(value)).join(' ')); + } + }; + const peerChatPersistenceService: IAgentHostPeerChatPersistenceService = { + _serviceBrand: undefined, + setRead: async (session: URI, chat: URI, isRead: boolean) => { + if (chat.toString() === failingChat) { + throw new Error('write failed'); + } + persisted.push({ session: session.toString(), chat: chat.toString(), isRead }); + }, + setArchived: async () => { }, + }; + const database = new TestSessionDatabase(); + const services = new ServiceCollection( + [ILogService, logService], + [IAgentHostPeerChatPersistenceService, peerChatPersistenceService], + [ISessionDataService, createSessionDataService(database)], + ); + const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); + const contributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); + disposables.add(contributions.registerContribution(ChatReadContribution as unknown as IConstructorSignature & { readonly id: string })); + + contributions.didDispatchAction(dispatchedAction(peerChat, session, { type: ActionType.ChatIsReadChanged, isRead: true })); + contributions.didDispatchAction(dispatchedAction(peerChat, session, { type: ActionType.ChatIsReadChanged, isRead: false })); + contributions.didDispatchAction(dispatchedAction(peerChat, session, { type: ActionType.ChatIsReadChanged, isRead: true }, 'rejected')); + contributions.didDispatchAction(dispatchedAction(buildDefaultChatUri(session), session, { type: ActionType.ChatIsReadChanged, isRead: true })); + contributions.didDispatchAction(dispatchedAction(failingChat, session, { type: ActionType.ChatIsReadChanged, isRead: true })); + await Promise.resolve(); + + assert.deepStrictEqual({ persisted, defaultChatMetadata: database.setMetadataCalls, errors }, { + persisted: [ + { session, chat: peerChat, isRead: true }, + { session, chat: peerChat, isRead: false }, + ], + defaultChatMetadata: [{ key: AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, value: 'true' }], + errors: [`Error: write failed [ChatReadContribution] Failed to persist read state for ${failingChat}`], }); }); diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index fd882ccb7268..ed5d73eaa4e2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -29,6 +29,7 @@ import { type ChatInputRequestedAction, type ChatToolCallReadyAction, type ChatErrorAction, type ChatToolCallCompleteAction, type ChatToolCallStartAction, } from '../../../../common/state/sessionActions.js'; +import type { AhpNotification } from '../../../../common/state/sessionProtocol.js'; import { CopilotCliConfigKey } from '../../../../common/copilotCliConfig.js'; import type { SessionMode } from '../../../../common/agentHostSchema.js'; import { AgentHostSessionResidencyLimitEnvVar } from '../../../../common/agentService.js'; @@ -541,8 +542,12 @@ export interface IDrivenTurnResult { responseText: string; } -export async function driveTurnToCompletion(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): Promise { - return driveTurn(c, buildDefaultChatUri(session), turnId, clientSeq, () => dispatchTurn(c, session, turnId, text, clientSeq)); +export interface IDriveTurnOptions { + expectUnread?: boolean; +} + +export async function driveTurnToCompletion(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number, options?: IDriveTurnOptions): Promise { + return driveTurn(c, buildDefaultChatUri(session), turnId, clientSeq, () => dispatchTurn(c, session, turnId, text, clientSeq), ChatInputResponseKind.Accept, getAcceptedAnswers, options); } export async function driveChatTurnToCompletion(c: TestProtocolClient, chat: string, turnId: string, text: string, clientSeq: number): Promise { @@ -583,7 +588,7 @@ export async function driveTurnWithAnswersToCompletion(c: TestProtocolClient, se return driveTurn(c, buildDefaultChatUri(session), turnId, clientSeq, () => dispatchTurn(c, session, turnId, text, clientSeq), ChatInputResponseKind.Accept, getAnswers); } -async function driveTurn(c: TestProtocolClient, chat: string, turnId: string, clientSeq: number, dispatch: () => void, inputResponse = ChatInputResponseKind.Accept, answerProvider = getAcceptedAnswers): Promise { +async function driveTurn(c: TestProtocolClient, chat: string, turnId: string, clientSeq: number, dispatch: () => void, inputResponse = ChatInputResponseKind.Accept, answerProvider = getAcceptedAnswers, options?: IDriveTurnOptions): Promise { c.clearReceived(); dispatch(); @@ -591,6 +596,7 @@ async function driveTurn(c: TestProtocolClient, chat: string, turnId: string, cl let nextClientSeq = clientSeq + 1; let sawInputRequest = false; let sawPendingConfirmation = false; + let terminalNotification!: AhpNotification; while (true) { const notification = await c.waitForNotification(n => { @@ -654,9 +660,20 @@ async function driveTurn(c: TestProtocolClient, chat: string, turnId: string, cl const action = getActionEnvelope(notification).action as { turnId: string }; assert.strictEqual(action.turnId, turnId); + terminalNotification = notification; break; } + if (options?.expectUnread !== false) { + await c.waitForNotification(n => { + const notifications = c.receivedNotifications(); + return notifications.indexOf(n) > notifications.indexOf(terminalNotification) + && isActionNotification(n, ActionType.ChatIsReadChanged) + && getActionEnvelope(n).channel === chat + && !(getActionEnvelope(n).action as { isRead: boolean }).isRead; + }, 90_000); + } + return { sawInputRequest, sawPendingConfirmation, responseText: getMarkdownResponseText(c) }; } diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml index 6015e54a7060..4d190990cdba 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml index 97e3976e010a..511b8dcecde5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml index 7cdae4809419..00c695066e7c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml index 5835f1788191..bc5e3e08533c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml index f7b2c98aa204..9786bb10cc90 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml @@ -65,3 +65,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml index 66b18cc6bc79..fe9be0ec61cb 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml index d8c0a3484542..575941a2957d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml @@ -54,3 +54,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml index 1036793ab32a..01e9db5d379a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml index 8d2cef378a72..3c302eb8bfac 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml index 4cc4856b1548..276249f089bf 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml index 926ebae7e842..6ead55fa0ee9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml index e97c4a7ce4c0..6a46ee9700aa 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml index 31b548179503..f82503803f19 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml index 356a1c25c868..cf175e1f732c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml @@ -39,6 +39,9 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged - clientToServer: - channel: ${chat_0} action: @@ -68,3 +71,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_1} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml index fd790438f7c3..46d0f6f035ef 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml index 987a0a1d5931..1ac3ed623e7e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml @@ -50,3 +50,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml index 4b6e8cf19aa2..c1f920aff731 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml @@ -60,3 +60,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml index 6382f7e374a8..f16c81678f69 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml @@ -60,3 +60,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml index 34f3cf4cf968..24bf96ac65cc 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml @@ -60,3 +60,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml index 201002c9eb17..0a142f84b858 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml @@ -60,3 +60,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml index ffe9d013a549..5d784aeeb881 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml @@ -60,3 +60,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml index 356a1c25c868..cf175e1f732c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml @@ -39,6 +39,9 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged - clientToServer: - channel: ${chat_0} action: @@ -68,3 +71,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_1} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml index c1a3f323ae42..3b1990463948 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml @@ -41,6 +41,9 @@ rounds: errorType: query message: 'Execution failed: 400 Injected recoverable E2E failure.' resumable: true + - channel: ${chat_0} + action: + type: chat/isReadChanged - clientToServer: - channel: ${chat_0} action: diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml index 955417613b96..2d9e69f99f52 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml @@ -113,3 +113,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml index 77799e9fa491..af567c2d69ec 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml index 930fe2ff1097..9b02e0b74ecf 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml index ad30e849e204..fda3df2db949 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml index 18862e5fefbf..e2b51275e9dc 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml index 2551fd858ac0..aa061207ce6e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml index 3292a0c3a430..8c9bd50f97fe 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml index ef7dff67c517..49ab3b24eb18 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml @@ -54,3 +54,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml index 2ad2efb5e0db..d2dc3ffadbce 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml index 4a5eee026b5a..d2e671830222 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml @@ -65,3 +65,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml index 1587ea280630..50f69226a075 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml @@ -65,3 +65,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml index 08e31de0bb41..fa730fe49005 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml @@ -65,3 +65,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml index 1ad0b35117c9..88fc41f8c230 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml @@ -65,3 +65,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml index 7a7ec7de1c9f..1e4b6f4d0511 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml index 043fa8bf0506..0e03ccdca238 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml @@ -58,6 +58,9 @@ rounds: part: kind: markdown content: PARENT_INITIAL_DONE + - channel: ${chat_0} + action: + type: chat/isReadChanged - responseTo: subscribe result: success - responseTo: subscribe @@ -117,6 +120,9 @@ rounds: part: kind: markdown content: PARENT_FOLLOWUP_ONE_DONE + - channel: ${chat_0} + action: + type: chat/isReadChanged - responseTo: subscribe result: success - clientToServer: @@ -174,5 +180,8 @@ rounds: part: kind: markdown content: PARENT_FOLLOWUP_TWO_DONE + - channel: ${chat_0} + action: + type: chat/isReadChanged - responseTo: subscribe result: success diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml index 356a1c25c868..cf175e1f732c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml @@ -39,6 +39,9 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged - clientToServer: - channel: ${chat_0} action: @@ -68,3 +71,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_1} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml index 592ba760f94a..037116c8f9bf 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml @@ -52,3 +52,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml index 47f3554d4b3f..c3a4d0a562c9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml @@ -58,3 +58,6 @@ rounds: action: type: chat/turnComplete turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/isReadChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts index e60b0baaa04f..633a5d635c5e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts @@ -261,7 +261,7 @@ suite('Agent Host E2E — Copilot OTel file exporter', function () { const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'copilot-otel-turn', createdSessions, URI.file(workspace)); await driveTurnToCompletion(client, sessionUri, 'turn-otel-export', 'Reply exactly "traced".', 1); - await driveTurnToCompletion(client, sessionUri, 'turn-otel-title', '/rename OTel Captured Title', 10); + await driveTurnToCompletion(client, sessionUri, 'turn-otel-title', '/rename OTel Captured Title', 10, { expectUnread: false }); const exported = await retry(async () => { const contents = await readFile(exportFile, 'utf8').catch(() => ''); if (!contents.includes('"traceId"') diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/annotationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/annotationsSuite.ts index 381d9e8df2ad..c04c2c6a3182 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/annotationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/annotationsSuite.ts @@ -225,7 +225,7 @@ export function defineAnnotationsTests(context: IAgentHostE2ETestContext): void } // A host-local command produces a completed, durable turn without model traffic. - await driveTurnToCompletion(context.client, sessionUri, 'turn-annotations-residency', '/rename Annotation Residency', nextClientSeq()); + await driveTurnToCompletion(context.client, sessionUri, 'turn-annotations-residency', '/rename Annotation Residency', nextClientSeq(), { expectUnread: false }); const annotation = { id: annotationId, origin: { session: sessionUri, chat: chatUri, turnId: 'turn-annotations-residency' }, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts index f9dc3cf246b8..d56e1ba5f9fd 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts @@ -269,18 +269,25 @@ export function defineCoreTests(context: IAgentHostE2ETestContext): void { const workspace = mkdtempSync(join(tmpdir(), 'ahp-model-switch-')); tempDirs.push(workspace); const sessionUri = await createRealSession(context.client, config, `model-switch-${config.provider}`, createdSessions, URI.file(workspace)); + const prompt = 'Reply exactly "model selected".'; + const observedRequestCount = context.observedModelRequestBodies.length; const result = await driveTurnWithModelToCompletion( context.client, sessionUri, 'turn-model-switch', - 'Reply exactly "model selected".', + prompt, modelSwitchTarget, 1, ); + const selectedModelRequest = context.observedModelRequestBodies + .slice(observedRequestCount) + .map(observedModelRequest) + .find(request => request.messages.some(message => modelContentText(message.content).includes(prompt))); + assert.ok(selectedModelRequest, 'Expected the selected-model turn to reach the provider'); assert.deepStrictEqual({ - model: observedModelRequest(context.observedModelRequestBodies.at(-1)).model, + model: selectedModelRequest.model, response: result.responseText.trim(), }, { model: modelSwitchWireTarget, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts index a980da65b08e..a55acd9ec0a3 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -312,7 +312,7 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { assert.deepStrictEqual({ turns: state.turns, activeTurn: state.activeTurn, status: state.status }, { turns: [], activeTurn: undefined, - status: SessionStatus.Idle, + status: SessionStatus.Idle | SessionStatus.IsRead, }); }, config.supportsMultipleChats); 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 d0df1583836e..50578a0413ca 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts @@ -292,6 +292,7 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void 'turn-rename-chat-seed', '/rename Seeded Chat', reserveClientSequenceBlock(), + { expectUnread: false }, ); const { tool } = await driveServerTool( session, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index 6e5f04ea8e64..372be4a3ceb0 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -112,7 +112,7 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) const workspace = fs.mkdtempSync(`${tmpdir()}/ahp-persistence-`); tempDirs.push(workspace); const sessionUri = await createRealSession(context.client, config, `persistence-${config.provider}`, createdSessions, URI.file(workspace)); - await driveTurnToCompletion(context.client, sessionUri, 'turn-persistence-rename', '/rename Persisted Session', 1); + await driveTurnToCompletion(context.client, sessionUri, 'turn-persistence-rename', '/rename Persisted Session', 1, { expectUnread: false }); await driveTurnToCompletion(context.client, sessionUri, 'turn-persistence-memory', 'Remember the exact code word VIOLET_REHYDRATE. Reply exactly "READY".', 10); await releaseAndRestoreSession(sessionUri); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 5054b6585bc8..dfa706516c88 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -2121,14 +2121,14 @@ suite('ProtocolServerHandler', () => { assert.deepStrictEqual({ listedMeta, summaryChanges }, { listedMeta: { providerOnly: true, live: 'current' }, summaryChanges: [{ + modifiedAt: startedAt, + status: SessionStatus.InProgress, chats: [{ - resource: defaultChatUri, + resource: buildDefaultChatUri(sessionUri), title: '', origin: { kind: MessageKind.User }, status: SessionStatus.InProgress, }], - modifiedAt: startedAt, - status: SessionStatus.InProgress, }], }); }); @@ -2328,14 +2328,16 @@ suite('ProtocolServerHandler', () => { test('listSessions carries ordered lightweight chats and default chat identity', async () => { const defaultChat = URI.parse(`${sessionUri}/chat/default`); const peerChat = URI.parse(`${sessionUri}/chat/peer`); + const unknownStatusChat = URI.parse(`${sessionUri}/chat/unknown`); agentService.listedSessions.push({ session: URI.parse(sessionUri), startTime: 1000, modifiedTime: 2000, summary: 'Session Summary', chats: [ - { chat: defaultChat, kind: 'default', summary: 'Default Chat' }, - { chat: peerChat, kind: 'peer', summary: 'Peer Chat', origin: { kind: ChatOriginKind.Fork, chat: defaultChat.toString(), turnId: 'turn-1' }, interactivity: ChatInteractivity.Hidden, archived: true }, + { chat: defaultChat, kind: 'default', summary: 'Default Chat', isRead: true }, + { chat: peerChat, kind: 'peer', summary: 'Peer Chat', origin: { kind: ChatOriginKind.Fork, chat: defaultChat.toString(), turnId: 'turn-1' }, interactivity: ChatInteractivity.Hidden, archived: true, isRead: false }, + { chat: unknownStatusChat, kind: 'peer', summary: 'Unknown Status Chat' }, ], }); @@ -2351,8 +2353,9 @@ suite('ProtocolServerHandler', () => { defaultChat: result.items[0].defaultChat, }, { chats: [ - { resource: defaultChat.toString(), title: 'Default Chat', origin: undefined }, - { resource: peerChat.toString(), title: 'Peer Chat', archived: true, origin: { kind: ChatOriginKind.Fork, chat: defaultChat.toString(), turnId: 'turn-1' }, interactivity: ChatInteractivity.Hidden }, + { resource: defaultChat.toString(), title: 'Default Chat', origin: undefined, status: SessionStatus.Idle | SessionStatus.IsRead }, + { resource: peerChat.toString(), title: 'Peer Chat', status: SessionStatus.Idle | SessionStatus.IsArchived, origin: { kind: ChatOriginKind.Fork, chat: defaultChat.toString(), turnId: 'turn-1' }, interactivity: ChatInteractivity.Hidden, archived: true }, + { resource: unknownStatusChat.toString(), title: 'Unknown Status Chat', origin: undefined }, ], defaultChat: defaultChat.toString(), }); diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 025387594037..01adfd647c5a 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -105,6 +105,8 @@ A session groups one or more chats and exposes a main chat. Providers advertise Chat origin and interactivity describe whether a chat is user-created, tool-created, interactive, read-only, or hidden. Presentation code uses those contracts instead of inferring behavior from resource shape. +When an unread chat in a multi-chat session becomes active, the view service asks the owning provider to mark that exact chat read. Activating the main chat does not directly mark the stored session aggregate read. The aggregate may remain unread after every chat becomes read, but it must be unread whenever any normal user-visible chat is unread. Tool-created subagent chats retain exact per-chat read state but do not participate in this aggregate. The main row always presents the main chat's exact read state, whether the chat hierarchy is expanded or collapsed; peer read state appears only on the corresponding peer row. Mark as Read and Mark as Unread on a session row target the main chat represented by that row; providers without independent chat read state may implement that operation through their session state. + ### Workspaces and quick chats `ISession.workspace` describes the complete workspace in which a session operates. `IChat.workspace` describes the effective workspace available to that chat and may be a subset of the session workspace. Each folder of a chat's workspace reports that folder's own repository and pull request information, so chats sharing a folder share its pull requests. Each folder also has its own Agent Merge settings: Agent Merge actions and indicators for the focused conversation follow the folder `IActiveSession.activeChat` works in, while session-wide surfaces such as the sessions list use the session folder (the main chat's). A folder that is a VS Code-created worktree (`.worktrees/`) reports its repository as the folder's project, so a chat working in such a worktree shows that project. Filesystem-facing UI and actions for the focused conversation use `IActiveSession.activeChat.workspace`; session lifecycle, creation, and list presentation continue to use the aggregate session workspace. In the compact sessions list, a chat row shows the folder its chat works in, on hover or focus, when the session spans more than one project and the chat works in exactly one folder. A quick chat is workspace-less by product intent and is identified through `ISession.isQuickChat`. An absent workspace alone does not prove that a session is a quick chat because workspace state may still be hydrating. diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 9af99db398a5..d686c06ff80d 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -358,8 +358,10 @@ export class ChatCompositeBar extends Disposable { this._tabDisposables.add(autorun(reader => { const title = chat.title.read(reader); const status = chat.status.read(reader); + const isRead = chat.isRead.read(reader); labelEl.textContent = title; - tab.setAttribute('aria-label', localize('chatTabAriaLabel', "{0}, {1}", title, getSessionConversationStatusAriaLabel(status))); + const label = localize('chatTabAriaLabel', "{0}, {1}", title, getSessionConversationStatusAriaLabel(status)); + tab.setAttribute('aria-label', isRead ? label : localize('chatTabUnreadAriaLabel', "{0}, unread", label)); })); // Lock icon shown for read-only (non-interactive) chats. diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 3f907cfb5ba5..4b92521ea98b 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -47,7 +47,7 @@ export const SessionHeaderTargetsChatContext = new RawContextKey('sessi export const SessionHeaderActiveChatIsPinnedContext = new RawContextKey('sessionHeaderActiveChatIsPinned', false, localize('sessionHeaderActiveChatIsPinned', "Whether the chat represented by a side-by-side chat group header is pinned")); export const SessionFocusedChatIsRenameTargetContext = new RawContextKey('sessionFocusedChatIsRenameTarget', false, localize('sessionFocusedChatIsRenameTarget', "Whether the focused chat group's visible chat is a non-main chat that should receive the chat-specific rename command instead of the session rename command")); export const SessionActiveChatIsDeletableContext = new RawContextKey('sessionActiveChatIsDeletable', false, localize('sessionActiveChatIsDeletable', "Whether the session's active chat can be permanently deleted from the tab strip, i.e. it is a real, user-created non-main chat (not the main chat and not a tool-spawned subagent chat, which are transient children). Used to scope the delete-chat keybinding")); -export const SessionIsReadContext = new RawContextKey('sessionIsRead', true, localize('sessionIsRead', "Whether the session has been marked as read")); +export const SessionIsReadContext = new RawContextKey('sessionIsRead', true, localize('sessionIsRead', "Whether the session's main chat has been marked as read")); export const SessionIsArchivedContext = new RawContextKey('sessionIsArchived', false, localize('sessionIsArchived', "Whether the session in scope is archived/marked as done (the active session globally, or a specific session within an isolated component such as the session view or a context menu overlay)")); export const SessionIsActiveContext = new RawContextKey('sessionIsActive', false, localize('sessionIsActive', "Whether the session in scope is in progress or needs input")); export const SessionHasChangesContext = new RawContextKey('sessionHasChanges', false, localize('sessionHasChanges', "Whether the session view's session has pending changes (insertions or deletions)")); 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 12174f05c213..e14f525231a1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -81,6 +81,8 @@ The provider cache owns adapter identity. Catalog notifications describe members Provider-specific metadata such as pull-request provenance, changesets, agent configuration, and external visibility is translated inside this provider. Shared Sessions code consumes only provider-neutral fields and capabilities. +Each chat's read state is projected from its chat summary independently of the session aggregate. In a multi-chat session, any unread normal user-visible chat forces the aggregate unread; Tool/subagent chats are excluded, and reading chats does not itself promote the aggregate to read. When supported by the negotiated protocol version, activating a chat or invoking Mark as Read or Mark as Unread on a session row dispatches the chat-level read-state action for the represented main chat and updates that chat facade optimistically; the host remains responsible for persistence and fan-out. + Selectable Agent Host changesets have one catalogue owner for each scope. The default chat exists from initial draft creation and owns repository-preparation changes before provider materialization. After materialization, the session continues publishing its cumulative Session Changes entry while each chat owns its repository and turn catalogue. Peer chats also own a Session Changes entry scoped to the edits made in that chat; because Git checkpoints capture the working tree shared by every chat, it is computed from the chat's tracked edits. The client keeps a chat-owned Session Changes entry and otherwise projects the session-owned entry into the chat catalogue, so the default chat shows the cumulative summary across all chats while every chat retains chat-scoped Branch, Uncommitted, This Turn, and Compare entries. Session workflow operations such as pull-request creation and Agent Merge are advertised by Branch Changes sourced from the default chat. Changesets from other scopes, along with Uncommitted, This Turn, and Compare entries, keep those operations filtered out. @@ -139,7 +141,7 @@ The local Agent Host maintains a host-wide `sessions_v2` SQLite registry and cat The row has two different ownership contracts. Registry identity and provenance (`session_uri`, provider, start time, external state, and registration source) remain authoritative. The list payload is a derived, rebuildable aggregate: central session/chat identity, provider state, and member-chat metadata can reproduce its canonical bytes and hash. Ordinary session-list reads use this stored aggregate rather than opening every member-chat database. -Peer-chat membership and routing data are authoritative in the central `session_chat_catalogs` and `session_chats` tables. The default chat is implicit in session identity; ordered peer rows retain their URI, provider backing, origin, and inherited-turn identity. A chat database owns its conversation content and chat-local metadata, including its durable provider backing and title. Central chat rows and the list payload retain only the copies needed to enumerate, route, and present the containing session. +Peer-chat membership and routing data are authoritative in the central `session_chat_catalogs` and `session_chats` tables. The default chat is implicit in session identity; ordered peer rows retain their URI, provider backing, origin, inherited-turn identity, and read state. The session database stores the default chat's independent read state separately from the session aggregate, while reconciliation clamps the aggregate unread if any normal user-visible chat is unread. Tool-created subagent chats keep their exact read state but are excluded from the aggregate. A chat database owns its conversation content and chat-local metadata, including its durable provider backing and title. Central chat rows and the list payload retain only the copies needed to enumerate, route, and present the containing session. During the downgrade-compatibility window, a revisioned participant mirrors central peer membership into the legacy `peerChats` session-metadata value. Current runtime reads remain central. A startup/restore importer may read that legacy value to incorporate chats created by an older build; after import, central membership wins and the compatibility mirror is regenerated. Failed mirror writes do not roll back central authority and remain unacknowledged for retry. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index efd560f769bb..c4c81afa4f8b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -54,7 +54,7 @@ import { ResolveSessionConfigResult, type SessionConfigPropertySchema, type Sess import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type CanvasReference, type CanvasState, type ChatOrigin, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, McpServerStatus, MessageKind, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, type SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { isActionKnownToVersion } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { ActionType, isChatAction, isSessionAction, NotificationType, type SessionSummaryChanges } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionCreationReference, readSessionEhcliAdoptable, readFolderGitHubState, readFolderScopeGitState, readSessionExternal, parseSessionGitHubData, readSessionGitHubData, readSessionGitState, readWorkingDirectoryKey, readWorkingDirectoryKeys, readWorkingDirectoryScopeId, readWorkingDirectoryScopeIds, withMigratedSessionGitHubState, withSessionGitHubData, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionCreationReference, withSessionExternal, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, withWorkingDirectoryKey, withWorkingDirectoryScopeId, type ChatState, type ChatSummary, type ISessionCreationReference as IProtocolSessionCreationReference, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isChatInSessionReadAggregate, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionCreationReference, readSessionEhcliAdoptable, readFolderGitHubState, readFolderScopeGitState, readSessionExternal, parseSessionGitHubData, readSessionGitHubData, readSessionGitState, readWorkingDirectoryKey, readWorkingDirectoryKeys, readWorkingDirectoryScopeId, readWorkingDirectoryScopeIds, withMigratedSessionGitHubState, withSessionGitHubData, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionCreationReference, withSessionExternal, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, withWorkingDirectoryKey, withWorkingDirectoryScopeId, type ChatState, type ChatSummary, type ISessionCreationReference as IProtocolSessionCreationReference, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -255,6 +255,7 @@ interface ISerializedSessionMetadata { readonly origin?: ChatOrigin; readonly interactivity?: ProtocolChatInteractivity; readonly archived?: boolean; + readonly isRead?: boolean; readonly changes?: ChangesSummary; }[]; /** Session folder's GitHub state, written by earlier versions; migrated on read. */ @@ -312,6 +313,7 @@ function serializeMetadata(meta: IAgentSessionMetadata, discovery?: IAgentHostSe origin: chat.origin, ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), ...(chat.archived === true ? { archived: true } : {}), + isRead: chat.isRead, ...(chat.changes !== undefined ? { changes: chat.changes } : {}), })), githubData: gitHubData.size > 0 ? Object.fromEntries(gitHubData) : undefined, @@ -405,6 +407,7 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta origin: chat.origin, ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), ...(chat.archived === true ? { archived: true } : {}), + isRead: chat.isRead, ...(chat.changes !== undefined ? { changes: chat.changes } : {}), })), ...(_meta ? { _meta } : {}), @@ -421,7 +424,11 @@ function chatMetadataFromSummary(summary: Pick; @@ -1037,11 +1055,14 @@ class AdditionalChat extends Disposable { private readonly _interactivity: ISettableObservable; private readonly _isNew: ISettableObservable; private readonly _isArchived: ISettableObservable; + private readonly _isRead: ISettableObservable; + private readonly _origin: ChatOrigin | undefined; private readonly _changesSummary: ISettableObservable; 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) { super(); this.backendUri = URI.parse(summary.resource); + this._origin = summary.origin; 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)); @@ -1055,6 +1076,7 @@ class AdditionalChat extends Disposable { this._interactivity = observableValue('chatInteractivity', toChatInteractivity(summary.interactivity)); this._isNew = observableValue('chatIsNew', isNew); this._isArchived = observableValue('chatIsArchived', isSessionStatusArchived(summary.status)); + this._isRead = observableValue('chatIsRead', isSessionStatusRead(summary.status)); this._changesSummary = observableValueOpts({ owner: this, debugName: 'chatChangesSummary', equalsFn: structuralEquals }, summary.changes ? toSessionChangesSummary(summary.changes) : undefined); const status = derived(this, reader => this._isNew.read(reader) ? SessionStatus.Untitled : this._status.read(reader)); const workspace = derived(this, reader => { @@ -1094,7 +1116,7 @@ class AdditionalChat extends Disposable { modelSource: this._withDetails(this._modelSource), mode: this._withDetails(this._mode), isArchived: this._isArchived, - isRead: constObservable(true), + isRead: this._withDetails(this._isRead), // Archived or replay-only chats must not expose mutating controls. interactivity, description: this._withDetails(this._description), @@ -1130,17 +1152,41 @@ class AdditionalChat extends Disposable { this._lastTurnEnd.set(modifiedAt, tx); this._interactivity.set(toChatInteractivity(summary.interactivity), tx); this._isArchived.set(isSessionStatusArchived(summary.status), tx); + this._isRead.set(isSessionStatusRead(summary.status), tx); this._setChangesSummary(summary.changes, tx); }); } - updateCatalogMetadata(title: string | undefined, interactivity: ProtocolChatInteractivity | undefined, archived: boolean | undefined, changes: ChangesSummary | undefined, tx?: ITransaction): void { + updateCatalogMetadata(title: string | undefined, interactivity: ProtocolChatInteractivity | undefined, archived: boolean | undefined, isRead: boolean | undefined, changes: ChangesSummary | undefined, tx?: ITransaction): void { this._title.set(title || localize('newChatTab', "New Chat"), tx); this._interactivity.set(toChatInteractivity(interactivity), tx); this._isArchived.set(archived === true, tx); + if (isRead !== undefined) { + this._isRead.set(isRead, tx); + } this._setChangesSummary(changes, tx); } + toMetadata(): IAgentSessionChatMetadata { + const changes = this._changesSummary.get(); + return { + chat: this.backendUri, + summary: this._title.get(), + kind: 'peer', + origin: this._origin, + interactivity: toProtocolChatInteractivity(this._interactivity.get()), + archived: this._isArchived.get(), + isRead: this._isRead.get(), + ...(changes ? { + changes: { + additions: changes.additions, + deletions: changes.deletions, + files: changes.files, + } + } : {}), + }; + } + /** Keeps the last known counts when an update does not carry them. */ private _setChangesSummary(changes: ChangesSummary | undefined, tx: ITransaction | undefined): void { if (changes) { @@ -1153,6 +1199,10 @@ class AdditionalChat extends Disposable { this._title.set(title || localize('newChatTab', "New Chat"), undefined); } + setRead(isRead: boolean): void { + this._isRead.set(isRead, undefined); + } + /** Present as `Untitled` until the first request is sent so the view shows the composer. */ markNew(): void { this._isNew.set(true, undefined); @@ -1277,8 +1327,11 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * (which may have been promoted by a running peer chat). */ private readonly _defaultChatStatusOverride = observableValue('defaultChatStatusOverride', undefined); + private readonly _defaultChatIsReadOverride = observableValue('defaultChatIsReadOverride', undefined); + private _defaultChatBackendUri: URI | undefined; private readonly _defaultChatUpdatedAt: ISettableObservable; private readonly _hasMultipleChats: ISettableObservable; + private readonly _aggregateChatResources = new ResourceSet(); private readonly _defaultChatWorkingDirectories = observableValueOpts({ owner: this, debugName: 'defaultChatWorkingDirectories', equalsFn: structuralEquals }, undefined); /** GitHub info per folder, keyed by working-directory key and created on demand. */ private readonly _folderGitHubInfos = new Map>(); @@ -1542,6 +1595,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._sessionOutput = sessionOutput; const defaultChatStatus = derived(this, reader => this._defaultChatStatusOverride.read(reader) ?? this.status.read(reader)); + const defaultChatIsRead = derived(this, reader => this._defaultChatIsReadOverride.read(reader) ?? this.isRead.read(reader)); const defaultChatWorkspace = derived(this, reader => { const workingDirectories = this._defaultChatWorkingDirectories.read(reader); return buildAgentHostChatWorkspace( @@ -1572,6 +1626,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { const defaultChatChanges = createChangesObservable(defaultChatChangesets); const hasMultipleChats = metadata.chats?.some(chat => chat.kind === 'peer') === true; this._hasMultipleChats = observableValue('hasMultipleChats', hasMultipleChats); + this._aggregateChatResources.add(this.resource); this._defaultChatUpdatedAt = observableValueOpts({ owner: this, debugName: 'defaultChatUpdatedAt', equalsFn: dateEquals }, hasMultipleChats ? undefined : this.updatedAt.get()); const mainChat: IChat = { resource: this.resource, @@ -1598,7 +1653,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { modelSource: this.modelSource, mode: this.mode, isArchived: this.isArchived, - isRead: this.isRead, + isRead: defaultChatIsRead, // Archived or replay-only chats must not expose mutating controls. interactivity: derived(this, reader => effectiveChatInteractivity( this.isArchived.read(reader) || (this._options.readOnly?.read(reader) ?? false), @@ -1683,11 +1738,24 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { applyChatMetadata(chats: readonly IAgentSessionChatMetadata[] | undefined, tx?: ITransaction): boolean { if (!chats) { - return false; + return this._markSessionUnreadForUnreadChat(tx); + } + this._aggregateChatResources.clear(); + for (const chat of chats) { + if (!isChatInSessionReadAggregate(chat.chat.toString(), chat.origin, chat.interactivity)) { + continue; + } + const chatId = parseChatUri(chat.chat)?.chatId; + this._aggregateChatResources.add(chat.kind === 'default' || !chatId + ? this.resource + : URI.from({ scheme: this._resourceScheme, path: `/${this._rawId}`, fragment: chatId })); } const previousChats = this._chatsObs.get(); const defaultChat = chats.find(chat => chat.kind === 'default'); + if (defaultChat) { + this._defaultChatBackendUri = defaultChat.chat; + } this._defaultChatTitleOverride.set(defaultChat?.summary || undefined, tx); this._defaultChatInteractivity.set(toChatInteractivity(defaultChat?.interactivity), tx); if (defaultChat?.changes) { @@ -1698,7 +1766,13 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { .filter(chat => chat.kind === 'peer') .map(chat => parseChatUri(chat.chat)?.chatId) .filter(chatId => chatId !== undefined); - this._hasMultipleChats.set(peerIds.length > 0, tx); + const hasMultipleChats = peerIds.length > 0; + this._hasMultipleChats.set(hasMultipleChats, tx); + if (!hasMultipleChats) { + this._defaultChatIsReadOverride.set(undefined, tx); + } else if (defaultChat?.isRead !== undefined) { + this._defaultChatIsReadOverride.set(defaultChat.isRead, tx); + } const survivingPeers = new Set(peerIds); const chatOutputResources = new ResourceSet(chats.map(chat => chat.chat)); for (const [chatId, entry] of this._additionalChats) { @@ -1725,14 +1799,18 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { entry = this._createAdditionalChat(chatId, { resource: chat.chat.toString(), title: chat.summary ?? '', - status: withSessionStatusFlag(ProtocolSessionStatus.Idle, ProtocolSessionStatus.IsArchived, chat.archived === true), + status: withSessionStatusFlag( + withSessionStatusFlag(ProtocolSessionStatus.Idle, ProtocolSessionStatus.IsArchived, chat.archived === true), + ProtocolSessionStatus.IsRead, + chat.isRead ?? true, + ), origin: chat.origin, interactivity: chat.interactivity, ...(chat.changes !== undefined ? { changes: chat.changes } : {}), }); this._additionalChats.set(chatId, entry); } else { - entry.updateCatalogMetadata(chat.summary, chat.interactivity, chat.archived, chat.changes, tx); + entry.updateCatalogMetadata(chat.summary, chat.interactivity, chat.archived, chat.isRead, chat.changes, tx); } ordered.push(entry.chat); } @@ -1752,7 +1830,59 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { const nextChats = ordered.length > 0 ? ordered : [this._defaultChat]; this._chatsObs.set(nextChats, tx); this._mainChatObs.set(this._defaultChat, tx); - return !arrayEquals(previousChats, nextChats); + const sessionReadChanged = this._markSessionUnreadForUnreadChat(tx); + return !arrayEquals(previousChats, nextChats) || sessionReadChanged; + } + + hasUnreadChat(): boolean { + return this._aggregateChatResources.size > 1 + && this._chatsObs.get().some(chat => this._aggregateChatResources.has(chat.resource) && !chat.isRead.get()); + } + + getUnreadAggregateChats(): readonly IChat[] { + if (this._aggregateChatResources.size <= 1) { + return []; + } + return this._chatsObs.get().filter(chat => this._aggregateChatResources.has(chat.resource) && !chat.isRead.get()); + } + + private _markSessionUnreadForUnreadChat(tx?: ITransaction): boolean { + if (!this.isRead.get() || !this.hasUnreadChat()) { + return false; + } + this.isRead.set(false, tx); + return true; + } + + getCurrentChatMetadata(chats: readonly IAgentSessionChatMetadata[] | undefined): readonly IAgentSessionChatMetadata[] | undefined { + if (!chats && this._additionalChats.size === 0) { + return undefined; + } + const defaultMetadata = chats?.find(chat => chat.kind === 'default'); + const currentDefaultMetadata: IAgentSessionChatMetadata = { + ...defaultMetadata, + chat: defaultMetadata?.chat ?? URI.parse(buildDefaultChatUri(this.backendUri)), + summary: this._defaultChat.title.get(), + kind: 'default', + interactivity: toProtocolChatInteractivity(this._defaultChatInteractivity.get()), + archived: this._defaultChat.isArchived.get(), + isRead: this._defaultChat.isRead.get(), + }; + const result: IAgentSessionChatMetadata[] = []; + for (const chat of this._chatsObs.get()) { + if (chat === this._defaultChat) { + result.push(currentDefaultMetadata); + continue; + } + const entry = chat.resource.fragment ? this._additionalChats.get(chat.resource.fragment) : undefined; + if (entry) { + result.push(entry.toMetadata()); + } + } + if (!result.includes(currentDefaultMetadata)) { + result.unshift(currentDefaultMetadata); + } + return result; } private _updateChatOutputResources(resources: ResourceSet): void { @@ -1773,7 +1903,20 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { const isDefault = (summary: ChatSummary): boolean => defaultChatUri ? summary.resource.toString() === defaultChatUri : isDefaultChatUri(summary.resource); + this._aggregateChatResources.clear(); + for (const chat of state.chats) { + if (!isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity)) { + continue; + } + const chatId = parseChatUri(chat.resource)?.chatId; + this._aggregateChatResources.add(isDefault(chat) || !chatId + ? this.resource + : URI.from({ scheme: this._resourceScheme, path: `/${this._rawId}`, fragment: chatId })); + } const defaultSummary = state.chats.find(isDefault); + if (defaultSummary) { + this._defaultChatBackendUri = URI.parse(defaultSummary.resource.toString()); + } this._defaultChatTitleOverride.set(defaultSummary?.title || undefined, undefined); this._defaultChatInteractivity.set(toChatInteractivity(defaultSummary?.interactivity), undefined); this._defaultChatWorkingDirectories.set(defaultSummary?.workingDirectories, undefined); @@ -1817,6 +1960,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // Single visible chat: the default chat is the session, so let it // reflect the aggregated session status directly (clear any override). this._defaultChatStatusOverride.set(undefined, undefined); + this._defaultChatIsReadOverride.set(undefined, undefined); if (this._additionalChats.size > 0) { this._additionalChats.clearAndDisposeAll(); } @@ -1832,6 +1976,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // Multiple chats: the default chat must show its own status, not the // session aggregate which may have been promoted by a running peer chat. this._defaultChatStatusOverride.set(defaultSummary ? mapProtocolStatus(defaultSummary.status) : undefined, undefined); + this._defaultChatIsReadOverride.set(defaultSummary ? isSessionStatusRead(defaultSummary.status) : undefined, undefined); const ordered: IChat[] = []; for (const summary of state.chats) { @@ -1864,6 +2009,15 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._chatsObs.set(ordered.length > 0 ? ordered : [this._defaultChat], tx); this._mainChatObs.set(main, tx); }); + this._markSessionUnreadForUnreadChat(); + } + + setDefaultChatRead(isRead: boolean): boolean { + if (!this._hasMultipleChats.get() || this._defaultChatIsReadOverride.get() === isRead) { + return false; + } + this._defaultChatIsReadOverride.set(isRead, undefined); + return true; } private _createAdditionalChat(chatId: string, summary: AgentHostChatSummary): AdditionalChat { @@ -1925,6 +2079,32 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { : this.resource; } + setChatRead(chatResource: URI, isRead: boolean): boolean { + if (isEqual(chatResource, this._defaultChat.resource)) { + if (this._hasMultipleChats.get()) { + this.setDefaultChatRead(isRead); + } else { + this.isRead.set(isRead, undefined); + } + this._markSessionUnreadForUnreadChat(); + return true; + } + const chat = chatResource.fragment ? this._additionalChats.get(chatResource.fragment) : undefined; + if (!chat) { + return false; + } + chat.setRead(isRead); + this._markSessionUnreadForUnreadChat(); + return true; + } + + getBackendChatResource(chatResource: URI): URI | undefined { + if (isEqual(chatResource, this._defaultChat.resource)) { + return this._defaultChatBackendUri; + } + return chatResource.fragment ? this._additionalChats.get(chatResource.fragment)?.backendUri : undefined; + } + /** Mark a peer chat new so it shows as `Untitled` until its first request. */ markChatAsNew(chatId: string): void { this._newChatIds.add(chatId); @@ -5848,9 +6028,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // The client resource is `:/[#chatId]`; drop the fragment to // recover the session resource, whose `sessionId` keys `_lastSessionStates`. const sessionResource = chatResource.with({ fragment: '' }); - const state = this._lastSessionStates.get(toSessionId(this.id, sessionResource)); + const sessionId = toSessionId(this.id, sessionResource); + const state = this._lastSessionStates.get(sessionId); if (!state) { - return undefined; + const rawId = this._rawIdFromChatId(sessionId); + return rawId ? this._sessionCache.get(rawId)?.getBackendChatResource(chatResource) : undefined; } // Look up the authoritative host-supplied backend chat URI rather than // constructing one: a peer chat's client fragment is exactly the chatId of @@ -6087,18 +6269,47 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement async setSessionReadState(sessionId: string, isRead: boolean): Promise { const rawId = this._rawIdFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (cached && rawId && cached.isRead.get() !== isRead) { - cached.isRead.set(isRead, undefined); + if (cached && rawId) { + if (isRead) { + for (const chat of cached.getUnreadAggregateChats()) { + await this.setChatReadState(sessionId, chat.resource, true); + } + } + const effectiveIsRead = isRead && !cached.hasUnreadChat(); + const sessionChanged = cached.isRead.get() !== effectiveIsRead; + if (!sessionChanged) { + return; + } + cached.isRead.set(effectiveIsRead, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); const connection = this.connection; if (connection) { const sessionUri = cached.backendUri; - const action = { type: ActionType.SessionIsReadChanged as const, isRead }; + const action = { type: ActionType.SessionIsReadChanged as const, isRead: effectiveIsRead }; connection.dispatch(sessionUri.toString(), action); } } } + async setChatReadState(sessionId: string, chatResource: URI, isRead: boolean): Promise { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + const connection = this.connection; + const initializeResult = connection?.initializeResult.get(); + const action = { type: ActionType.ChatIsReadChanged as const, isRead }; + if (!cached || !connection || !initializeResult || !isActionKnownToVersion(action, initializeResult.protocolVersion)) { + return false; + } + const backendChatResource = this.getBackendChatResource(chatResource); + if (!backendChatResource || !cached.setChatRead(chatResource, isRead)) { + throw new Error(localize('chatNotFound', "The chat could not be found.")); + } + this._cacheDirty = true; + this._keepSessionStateAlive(cached.sessionId); + connection.dispatch(backendChatResource.toString(), action); + return true; + } + async deleteSession(sessionId: string): Promise { await this.deleteSessions([sessionId]); } @@ -7165,6 +7376,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } cached.applyChatCatalog(state); + this._cacheDirty = true; } /** @@ -7340,10 +7552,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * immediately so {@link getSessions} returns them before the first * `listSessions()` round-trip resolves. * - * `legacyStorageKey`, when given, is removed so stale entries are discarded. + * `legacyStorageKeys`, when given, are removed so stale entries are discarded. */ - protected _enableSessionCachePersistence(storageKey: string, legacyStorageKey?: string): void { - if (legacyStorageKey) { + protected _enableSessionCachePersistence(storageKey: string, legacyStorageKeys?: string | readonly string[]): void { + for (const legacyStorageKey of typeof legacyStorageKeys === 'string' ? [legacyStorageKeys] : legacyStorageKeys ?? []) { this._storageService.remove(legacyStorageKey, StorageScope.APPLICATION); } this._sessionCacheStorageKey = storageKey; @@ -7410,6 +7622,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement modifiedTime: adapter.updatedAt.get().getTime(), changes: adapter.changesSummary.get(), project: adapter.project, + chats: adapter.getCurrentChatMetadata(base.chats), // Session-state and summary updates can relocate an existing session. workingDirectories: adapter.workingDirectories, status: withSessionStatusFlag( @@ -7781,8 +7994,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _handleIsReadChanged(session: string, isRead: boolean): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); - if (cached && cached.isRead.get() !== isRead) { - cached.isRead.set(isRead, undefined); + if (cached) { + const effectiveIsRead = isRead && !cached.hasUnreadChat(); + const sessionChanged = cached.isRead.get() !== effectiveIsRead; + if (!sessionChanged) { + return; + } + cached.isRead.set(effectiveIsRead, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); } } @@ -7814,7 +8032,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement didChange = true; } - const isRead = !!(changes.status & ProtocolSessionStatus.IsRead); + const isRead = !!(changes.status & ProtocolSessionStatus.IsRead) && !cached.hasUnreadChat(); if (isRead !== cached.isRead.get()) { cached.isRead.set(isRead, tx); didChange = true; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index f298b18d585d..dca4b0a3405c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -57,9 +57,9 @@ const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; * single machine-wide local agent host, so a fixed key (no per-authority * suffix) is used; the base provider persists under `StorageScope.APPLICATION`. */ -const LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY = 'localAgentHost.cachedSessions.v2'; +const LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY = 'localAgentHost.cachedSessions.v4'; // TODO@sandy081 Remove this legacy cache-key cleanup after 2026-10-14. -const LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY_LEGACY = 'localAgentHost.cachedSessions'; +const LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEYS_LEGACY = ['localAgentHost.cachedSessions.v3', 'localAgentHost.cachedSessions.v2', 'localAgentHost.cachedSessions']; /** * Local-window sessions provider backed by the in-process @@ -171,7 +171,7 @@ export class LocalAgentHostSessionsProvider extends DevContainerAgentHostSession // local sessions immediately at startup, before the agent host has // started and the first `listSessions()` round-trip (gated on // authentication settling below) reconciles them. - this._enableSessionCachePersistence(LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY, LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY_LEGACY); + this._enableSessionCachePersistence(LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY, LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEYS_LEGACY); const onDidChangeResourceLabelHomes = Event.any(this._onDidChangeSessionsImmediately, this._onDidChangeDraftSessions.event); const updateResourceLabelHomes = () => { 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 5a5cbeb2f55e..581357f6aa28 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 @@ -329,7 +329,7 @@ class MockAgentHostService extends mock() { override dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { this.dispatchedActions.push({ channel, action, clientId: this.clientId, clientSeq: this._nextSeq++ }); - if (action.type === ActionType.ChatIsArchivedChanged) { + if (action.type === ActionType.ChatIsArchivedChanged || action.type === ActionType.ChatIsReadChanged) { const session = URI.parse(parseRequiredSessionUriFromChatUri(channel)); const existing = this._sessionStateValues.get(session.toString()) as SessionState | undefined; if (existing) { @@ -338,9 +338,13 @@ class MockAgentHostService extends mock() { chats: existing.chats.map(summary => summary.resource === channel ? { ...summary, - status: action.isArchived - ? summary.status | ProtocolSessionStatus.IsArchived - : summary.status & ~ProtocolSessionStatus.IsArchived, + status: action.type === ActionType.ChatIsArchivedChanged + ? action.isArchived + ? summary.status | ProtocolSessionStatus.IsArchived + : summary.status & ~ProtocolSessionStatus.IsArchived + : action.isRead + ? summary.status | ProtocolSessionStatus.IsRead + : summary.status & ~ProtocolSessionStatus.IsRead, } : summary), }); @@ -2000,6 +2004,54 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('hydrates current chat read state before live metadata is available', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + const previousHost = new MockAgentHostService(); + disposables.add(toDisposable(() => previousHost.dispose())); + const rawId = 'cached-chat-read-state'; + const sessionUri = AgentSession.uri('copilotcli', rawId); + const defaultChat = URI.parse(buildDefaultChatUri(sessionUri)); + const peerChat = URI.parse(buildChatUri(sessionUri, 'peer-1')); + previousHost.addSession(createSession(rawId, { + summary: 'Cached Chat Read State', + status: ProtocolSessionStatus.Idle, + chats: [ + { chat: defaultChat, kind: 'default', summary: 'Default', isRead: false }, + ], + })); + const previousProvider = createProvider(disposables, previousHost, undefined, { storageService }); + await timeout(0); + const previousSession = previousProvider.getSessions()[0]; + previousProvider.getSessionConfig(previousSession.sessionId); + previousHost.setSessionState(rawId, 'copilotcli', { + provider: 'copilotcli', + title: 'Session', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [ + { resource: defaultChat.toString(), title: 'Default', status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead, modifiedAt: new Date(0).toISOString() }, + { resource: peerChat.toString(), title: 'Peer', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }, + ], + defaultChat: defaultChat.toString(), + }); + await storageService.flush(); + + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const nextProvider = createProvider(disposables, nextHost, undefined, { storageService }); + const restored = nextProvider.getSessions()[0]; + + assert.deepStrictEqual({ + sessionIsRead: restored.isRead.get(), + chatReadState: restored.chats.get().map(chat => chat.isRead.get()), + }, { + sessionIsRead: false, + chatReadState: [true, false], + }); + })); + test('hydrates persisted change stats before the live list is available', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const storageService = disposables.add(new InMemoryStorageService()); const previousHost = new MockAgentHostService(); @@ -2141,10 +2193,8 @@ suite('LocalAgentHostSessionsProvider', () => { })); test('discards a legacy cache entry so read state is rebuilt from the host', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - // Storage-key literals of the pre-`.v2` cache schema, whose entries - // carried a stale `isRead: true` written by the old always-read adapter. - const LEGACY_KEY = 'localAgentHost.cachedSessions'; - const CURRENT_KEY = 'localAgentHost.cachedSessions.v2'; + const LEGACY_KEYS = ['localAgentHost.cachedSessions', 'localAgentHost.cachedSessions.v2', 'localAgentHost.cachedSessions.v3']; + const CURRENT_KEY = 'localAgentHost.cachedSessions.v4'; const storageService = disposables.add(new InMemoryStorageService()); // Simulate a previous (old-schema) window: persist a session, then move @@ -2152,7 +2202,9 @@ suite('LocalAgentHostSessionsProvider', () => { await persistCachedSessions(disposables, storageService, [createSession('legacy-1', { summary: 'Legacy One' })]); const snapshot = storageService.get(CURRENT_KEY, StorageScope.APPLICATION); assert.ok(snapshot, 'precondition: current-key snapshot should exist'); - storageService.store(LEGACY_KEY, snapshot, StorageScope.APPLICATION, StorageTarget.USER); + for (const legacyKey of LEGACY_KEYS) { + storageService.store(legacyKey, snapshot, StorageScope.APPLICATION, StorageTarget.USER); + } storageService.remove(CURRENT_KEY, StorageScope.APPLICATION); // Fresh launch with authentication pending so no live refresh runs: the @@ -2164,10 +2216,10 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual({ cachedSessions: provider.getSessions().length, - legacyKeyPresent: storageService.get(LEGACY_KEY, StorageScope.APPLICATION) !== undefined, + legacyKeysPresent: LEGACY_KEYS.filter(key => storageService.get(key, StorageScope.APPLICATION) !== undefined), }, { cachedSessions: 0, - legacyKeyPresent: false, + legacyKeysPresent: [], }); })); @@ -2248,7 +2300,7 @@ suite('LocalAgentHostSessionsProvider', () => { origin: undefined, } as ActionEnvelope); await storageService.flush(); - const repersisted = JSON.parse(storageService.get('localAgentHost.cachedSessions.v2', StorageScope.APPLICATION)!) as Array<{ multiRoot?: typeof multiRoot }>; + const repersisted = JSON.parse(storageService.get('localAgentHost.cachedSessions.v4', StorageScope.APPLICATION)!) as Array<{ multiRoot?: typeof multiRoot }>; assert.deepStrictEqual({ repersisted: repersisted[0].multiRoot, @@ -7580,11 +7632,11 @@ suite('LocalAgentHostSessionsProvider', () => { return { resource, title, status, modifiedAt, workingDirectories: workingDirectories ? [...workingDirectories] : undefined }; } - function makeState(chats: ChatSummary[], opts?: { sessionTitle?: string; defaultChat?: string; configValues?: Record; meta?: SessionState['_meta']; workingDirectories?: readonly string[] }): SessionState { + function makeState(chats: ChatSummary[], opts?: { sessionTitle?: string; defaultChat?: string; configValues?: Record; meta?: SessionState['_meta']; workingDirectories?: readonly string[]; status?: ProtocolSessionStatus }): SessionState { return { provider: 'copilotcli', title: opts?.sessionTitle ?? 'Session', - status: ProtocolSessionStatus.Idle, + status: opts?.status ?? ProtocolSessionStatus.Idle, lifecycle: SessionLifecycle.Ready, activeClients: [], chats, @@ -7757,7 +7809,7 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('list metadata surfaces peer titles and archived state without subscribing and loads stable chat details while observed', async () => { + test('list metadata surfaces exact chat state without subscribing and loads stable chat details while observed', async () => { agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: {} } as AgentInfo]); const rawId = 'multi-catalog-list'; const sessionUri = AgentSession.uri('copilotcli', rawId); @@ -7765,9 +7817,10 @@ suite('LocalAgentHostSessionsProvider', () => { const peerChat = URI.parse(buildChatUri(sessionUri, 'peer-1')); agentHost.addSession(createSession(rawId, { summary: 'Session', + status: ProtocolSessionStatus.Idle, chats: [ - { chat: defaultChat, kind: 'default', summary: 'Default' }, - { chat: peerChat, kind: 'peer', summary: 'Catalog Peer', interactivity: ProtocolChatInteractivity.Hidden, archived: true }, + { chat: defaultChat, kind: 'default', summary: 'Default', isRead: true }, + { chat: peerChat, kind: 'peer', summary: 'Catalog Peer', interactivity: ProtocolChatInteractivity.Hidden, archived: true, isRead: false }, ], })); const provider = createProvider(disposables, agentHost); @@ -7786,12 +7839,16 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual({ titles: session.chats.get().map(chat => chat.title.get()), interactivity: session.chats.get().map(chat => chat.interactivity.get()), + sessionIsRead: session.isRead.get(), + chatReadState: session.chats.get().map(chat => chat.isRead.get()), observedInteractivity, observedArchived, sessionSubscriptions: agentHost.sessionSubscribeCounts.get(sessionUri.toString()) ?? 0, }, { titles: ['Default', 'Catalog Peer'], interactivity: [ChatInteractivity.Full, ChatInteractivity.Hidden], + sessionIsRead: false, + chatReadState: [true, false], observedInteractivity: ChatInteractivity.Hidden, observedArchived: true, sessionSubscriptions: 0, @@ -7802,7 +7859,7 @@ suite('LocalAgentHostSessionsProvider', () => { initialPeer.status.read(reader); })); agentHost.setSessionState(rawId, 'copilotcli', makeState([ - makeChatSummary(defaultChat.toString(), 'Default'), + makeChatSummary(defaultChat.toString(), 'Default', ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead), makeChatSummary(peerChat.toString(), 'Hydrated Peer', ProtocolSessionStatus.InProgress), ], { defaultChat: defaultChat.toString() })); @@ -7811,12 +7868,14 @@ suite('LocalAgentHostSessionsProvider', () => { peerIdentityPreserved: session.chats.get()[1] === initialPeer, peerTitle: session.chats.get()[1].title.get(), peerStatus: session.chats.get()[1].status.get(), + chatReadState: session.chats.get().map(chat => chat.isRead.get()), supportsMultipleChats: session.capabilities.get().supportsMultipleChats, }, { sessionSubscriptions: 1, peerIdentityPreserved: true, peerTitle: 'Hydrated Peer', peerStatus: SessionStatus.InProgress, + chatReadState: [true, false], supportsMultipleChats: false, }); }); @@ -7980,6 +8039,108 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('peer read state follows the exact chat summary', () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'multi-read-state'); + const sessionUri = AgentSession.uri('copilotcli', 'multi-read-state').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer-1'); + + agentHost.setSessionState('multi-read-state', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + makeChatSummary(peerChat, 'Peer', ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead), + ], { defaultChat })); + const peer = session.chats.get()[1]; + const read = peer.isRead.get(); + + agentHost.setSessionState('multi-read-state', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + makeChatSummary(peerChat, 'Peer', ProtocolSessionStatus.Idle), + ], { defaultChat })); + + assert.deepStrictEqual({ read, unread: peer.isRead.get() }, { read: true, unread: false }); + }); + + test('lightweight chat status updates exact read state and absence remains unknown', () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'compact-read-state'); + const sessionUri = AgentSession.uri('copilotcli', 'compact-read-state').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer-1'); + + agentHost.setSessionState('compact-read-state', 'copilotcli', makeState([ + makeChatSummary(defaultChat, '', ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead), + makeChatSummary(peerChat, 'Peer', ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead), + ], { defaultChat, status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead })); + fireSessionSummaryChanged(agentHost, 'compact-read-state', { + status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead, + chats: [ + { resource: defaultChat, title: '', status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead }, + { resource: peerChat, title: 'Peer', status: ProtocolSessionStatus.Idle }, + ], + defaultChat, + }); + const afterKnownStatus = { + session: session.isRead.get(), + chats: session.chats.get().map(chat => chat.isRead.get()), + }; + fireSessionSummaryChanged(agentHost, 'compact-read-state', { + chats: [ + { resource: defaultChat, title: '' }, + { resource: peerChat, title: 'Peer' }, + ], + defaultChat, + }); + + assert.deepStrictEqual({ + afterKnownStatus, + afterUnknownStatus: { + session: session.isRead.get(), + chats: session.chats.get().map(chat => chat.isRead.get()), + }, + }, { + afterKnownStatus: { session: false, chats: [true, false] }, + afterUnknownStatus: { session: false, chats: [true, false] }, + }); + }); + + test('tool chat unread state does not clamp the session aggregate', () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'tool-read-state'); + const sessionUri = AgentSession.uri('copilotcli', 'tool-read-state').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const toolChat = buildSubagentChatUri(sessionUri, 'tool-call'); + const peerChat = buildChatUri(sessionUri, 'peer'); + fireSessionSummaryChanged(agentHost, 'tool-read-state', { + status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead, + }); + + agentHost.setSessionState('tool-read-state', 'copilotcli', makeState([ + makeChatSummary(defaultChat, '', ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead), + { + ...makeChatSummary(toolChat, 'Tool'), + origin: { kind: ProtocolChatOriginKind.Tool, chat: defaultChat, toolCallId: 'tool-call' }, + }, + ], { defaultChat, status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead })); + const withUnreadTool = session.isRead.get(); + agentHost.setSessionState('tool-read-state', 'copilotcli', makeState([ + makeChatSummary(defaultChat, '', ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead), + { + ...makeChatSummary(toolChat, 'Tool'), + origin: { kind: ProtocolChatOriginKind.Tool, chat: defaultChat, toolCallId: 'tool-call' }, + }, + makeChatSummary(peerChat, 'Peer'), + ], { defaultChat, status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead })); + + assert.deepStrictEqual({ + withUnreadTool, + withUnreadPeer: session.isRead.get(), + }, { + withUnreadTool: true, + withUnreadPeer: false, + }); + }); + test('Agent Merge settings are per folder: a peer chat writes its own folder while the session folder keeps earlier settings', async () => { const provider = createProvider(disposables, agentHost); const primaryDirectory = URI.file('/workspace-primary'); @@ -8550,6 +8711,183 @@ suite('LocalAgentHostSessionsProvider', () => { ); }); + test('peer chat read state updates optimistically and dispatches to the host-supplied chat resource', async () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'chat-read-resource'); + const backendSessionUri = AgentSession.uri('copilotcli', 'backend-chat-read').toString(); + const defaultChat = buildDefaultChatUri(backendSessionUri); + const peerChat = buildChatUri(backendSessionUri, 'peer-1'); + agentHost.setSessionState('chat-read-resource', 'copilotcli', makeState([ + makeChatSummary(defaultChat, '', ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead), + { ...makeChatSummary(peerChat, 'Peer'), origin: { kind: ProtocolChatOriginKind.User } }, + ], { defaultChat })); + const [main, peer] = session.chats.get(); + const before = { main: main.isRead.get(), peer: peer.isRead.get() }; + + await provider.setChatReadState(session.sessionId, peer.resource, true); + + assert.deepStrictEqual({ + before, + isRead: peer.isRead.get(), + action: agentHost.dispatchedActions + .filter(dispatch => dispatch.action.type === ActionType.ChatIsReadChanged) + .map(dispatch => ({ + channel: dispatch.channel, + isRead: dispatch.action.type === ActionType.ChatIsReadChanged ? dispatch.action.isRead : undefined, + })), + }, { + before: { main: true, peer: false }, + isRead: true, + action: [{ channel: peerChat, isRead: true }], + }); + }); + + test('chat read state reports protocol rejection so callers can retry after initialization', async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), protocolVersion: '0.8.0' }, undefined); + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'chat-read-retry'); + const sessionUri = AgentSession.uri('copilotcli', 'chat-read-retry').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + agentHost.setSessionState('chat-read-retry', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + ], { defaultChat })); + const main = session.mainChat.get(); + + const rejected = await provider.setChatReadState(session.sessionId, main.resource, true); + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), protocolVersion: PROTOCOL_VERSION }, undefined); + const accepted = await provider.setChatReadState(session.sessionId, main.resource, true); + + assert.deepStrictEqual({ + rejected, + accepted, + isRead: main.isRead.get(), + actions: agentHost.dispatchedActions.filter(dispatch => dispatch.action.type === ActionType.ChatIsReadChanged).length, + }, { + rejected: false, + accepted: true, + isRead: true, + actions: 1, + }); + }); + + test('single default chat read state updates optimistically and dispatches to the host', async () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'single-chat-read'); + const sessionUri = AgentSession.uri('copilotcli', 'single-chat-read').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + agentHost.setSessionState('single-chat-read', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + ], { defaultChat })); + const main = session.mainChat.get(); + const before = { session: session.isRead.get(), main: main.isRead.get() }; + + await provider.setChatReadState(session.sessionId, main.resource, true); + const afterRead = { session: session.isRead.get(), main: main.isRead.get() }; + await provider.setChatReadState(session.sessionId, main.resource, false); + + assert.deepStrictEqual({ + before, + afterRead, + afterUnread: { session: session.isRead.get(), main: main.isRead.get() }, + actions: agentHost.dispatchedActions + .filter(dispatch => dispatch.action.type === ActionType.ChatIsReadChanged) + .map(dispatch => ({ + channel: dispatch.channel, + isRead: dispatch.action.type === ActionType.ChatIsReadChanged ? dispatch.action.isRead : undefined, + })), + }, { + before: { session: false, main: false }, + afterRead: { session: true, main: true }, + afterUnread: { session: false, main: false }, + actions: [ + { channel: defaultChat, isRead: true }, + { channel: defaultChat, isRead: false }, + ], + }); + }); + + test('default chat read state dispatches before session state hydrates', async () => { + const rawId = 'default-chat-read-before-hydration'; + const sessionUri = AgentSession.uri('copilotcli', rawId); + const defaultChat = URI.parse(buildDefaultChatUri(sessionUri)); + agentHost.addSession(createSession(rawId, { + status: ProtocolSessionStatus.Idle, + chats: [ + { chat: defaultChat, kind: 'default', summary: 'Default', isRead: false }, + ], + })); + const provider = createProvider(disposables, agentHost); + await timeout(0); + const session = provider.getSessions().find(session => AgentSession.id(session.resource.toString()) === rawId); + assert.ok(session); + + await provider.setChatReadState(session.sessionId, session.mainChat.get().resource, true); + + assert.deepStrictEqual({ + session: session.isRead.get(), + main: session.mainChat.get().isRead.get(), + actions: agentHost.dispatchedActions + .filter(dispatch => dispatch.action.type === ActionType.ChatIsReadChanged) + .map(dispatch => ({ + channel: dispatch.channel, + isRead: dispatch.action.type === ActionType.ChatIsReadChanged ? dispatch.action.isRead : undefined, + })), + }, { + session: true, + main: true, + actions: [{ channel: defaultChat.toString(), isRead: true }], + }); + }); + + test('explicit session read marks every aggregate chat and the session read', async () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'default-chat-read'); + const backendSessionUri = AgentSession.uri('copilotcli', 'default-chat-read').toString(); + const defaultChat = buildDefaultChatUri(backendSessionUri); + const peerChat = buildChatUri(backendSessionUri, 'peer-1'); + const hiddenChat = buildChatUri(backendSessionUri, 'hidden'); + agentHost.setSessionState('default-chat-read', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + { ...makeChatSummary(peerChat, 'Peer', ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead), origin: { kind: ProtocolChatOriginKind.User } }, + { ...makeChatSummary(hiddenChat, 'Hidden'), interactivity: ProtocolChatInteractivity.Hidden }, + ], { defaultChat })); + const [main, peer, hidden] = session.chats.get(); + + await provider.setSessionReadState(session.sessionId, true); + const afterSessionRead = { + session: session.isRead.get(), + main: main.isRead.get(), + peer: peer.isRead.get(), + }; + await provider.setSessionReadState(session.sessionId, false); + + assert.deepStrictEqual({ + afterSessionRead, + hidden: hidden.isRead.get(), + final: { + session: session.isRead.get(), + main: main.isRead.get(), + peer: peer.isRead.get(), + }, + action: agentHost.dispatchedActions + .map(dispatch => ({ + channel: dispatch.channel, + type: dispatch.action.type, + isRead: dispatch.action.type === ActionType.SessionIsReadChanged || dispatch.action.type === ActionType.ChatIsReadChanged ? dispatch.action.isRead : undefined, + })) + .filter(action => action.isRead !== undefined), + }, { + afterSessionRead: { session: true, main: true, peer: true }, + hidden: false, + final: { session: false, main: true, peer: true }, + action: [ + { channel: defaultChat, type: ActionType.ChatIsReadChanged, isRead: true }, + { channel: backendSessionUri, type: ActionType.SessionIsReadChanged, isRead: true }, + { channel: backendSessionUri, type: ActionType.SessionIsReadChanged, isRead: false }, + ], + }); + }); + test('side chats cannot be archived independently', () => { const provider = createProvider(disposables, agentHost); const session = setupMultiChatSession(provider, 'side-chat-archive'); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 092ecf1c8337..305e0f27ad18 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -57,11 +57,11 @@ import { readAgentDevContainerWorktreeMetadata } from '../../../../../platform/a import { INewSessionComposerService } from '../../../chat/browser/newSessionComposerService.js'; /** Storage key prefix for cached session summaries, per remote address. */ -const CACHED_SESSIONS_STORAGE_PREFIX = 'remoteAgentHost.cachedSessions.v2.'; +const CACHED_SESSIONS_STORAGE_PREFIX = 'remoteAgentHost.cachedSessions.v4.'; const DEV_CONTAINER_ARCHIVE_CONFIRMATION_TIMEOUT_MS = 5000; const DEV_CONTAINER_IDLE_POLL_INTERVAL_MS = 60 * 1000; // TODO@sandy081 Remove this legacy cache-key cleanup after 2026-10-14. -const CACHED_SESSIONS_STORAGE_PREFIX_LEGACY = 'remoteAgentHost.cachedSessions.'; +const CACHED_SESSIONS_STORAGE_PREFIXES_LEGACY = ['remoteAgentHost.cachedSessions.v3.', 'remoteAgentHost.cachedSessions.v2.', 'remoteAgentHost.cachedSessions.']; function toLocalProjectUri(uri: URI, connectionAuthority: string): URI { return uri.scheme === Schemas.file ? toAgentHostUri(uri, connectionAuthority) : uri; @@ -359,7 +359,7 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio listFolders: (query, token) => this._listRemoteFolders(query, token), }]; - this._enableSessionCachePersistence(this._storageKey, `${CACHED_SESSIONS_STORAGE_PREFIX_LEGACY}${this._connectionAuthority}`); + this._enableSessionCachePersistence(this._storageKey, CACHED_SESSIONS_STORAGE_PREFIXES_LEGACY.map(prefix => `${prefix}${this._connectionAuthority}`)); this.updateResourceLabelHomes(); this._register(this._remoteAgentHostService.onDidChangeDisplayName(address => { if (address === normalizeRemoteAgentHostAddress(this.remoteAddress)) { diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 764063103841..13a254bbee63 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -990,6 +990,7 @@ } } + &.unread .session-chat-title, &.needs-input .session-chat-title { color: var(--vscode-strongForeground); } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 2c44258b39b1..3a62d0f8321b 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -327,6 +327,12 @@ function getSessionRowStatus(session: ISession, reader: IReader | undefined, der return rowStatus; } +function getSessionRowIsRead(session: ISession, reader: IReader | undefined, deriveFromMainChat: boolean): boolean { + return deriveFromMainChat + ? session.mainChat.read(reader).isRead.read(reader) + : session.isRead.read(reader); +} + function isSessionActive(session: ISession, reader: IReader | undefined): boolean { return isActiveSessionStatus(session.status.read(reader)); } @@ -724,6 +730,7 @@ class SessionChatItemRenderer implements ITreeRenderer 1 ? getHighestPriorityPullRequestIcon( element.chat.workspace.read(reader)?.folders.flatMap(folder => @@ -738,12 +745,13 @@ class SessionChatItemRenderer implements ITreeRenderer | undefined; template.elementDisposables.add(autorun(reader => { + const collapsed = this.options.collapsedSessionIds?.read(reader).has(element.sessionId) ?? true; const sessionStatus = getSessionRowStatus( element, reader, !!this.options.deriveStatusFromMainChat, - this.options.collapsedSessionIds?.read(reader).has(element.sessionId) ?? true, + collapsed, ); template.statusContext.set(sessionStatus); - const isRead = element.isRead.read(reader); - template.isReadContext.set(isRead); + const isRead = getSessionRowIsRead(element, reader, !!this.options.deriveStatusFromMainChat); + template.isReadContext.set(element.mainChat.read(reader).isRead.read(reader)); const isArchived = element.isArchived.read(reader); template.isArchivedContext.set(isArchived); const isQuickChat = element.isQuickChat?.read(reader) ?? false; @@ -2014,6 +2023,13 @@ const enum SessionHeaderStatus { Unread, } +function hasUnreadSessionListChat(session: ISession, reader: IReader): boolean { + if (!session.mainChat.read(reader).isRead.read(reader)) { + return true; + } + return getSessionListChats(session, reader).some(chat => !chat.isRead.read(reader)); +} + function getSessionHeaderStatus(sessions: readonly ISession[], reader: IReader, sessionsWithFailingCI: ReadonlySet | undefined): SessionHeaderStatus | undefined { let hasFailingCI = false; let hasUnread = false; @@ -2026,7 +2042,7 @@ function getSessionHeaderStatus(sessions: readonly ISession[], reader: IReader, return SessionHeaderStatus.NeedsInput; } hasFailingCI ||= status !== SessionStatus.InProgress && sessionsWithFailingCI?.has(session.sessionId) === true; - hasUnread ||= !session.isRead.read(reader); + hasUnread ||= hasUnreadSessionListChat(session, reader); } return hasFailingCI ? SessionHeaderStatus.FailingCI : hasUnread ? SessionHeaderStatus.Unread : undefined; } @@ -2054,7 +2070,7 @@ function couldShowSessionHeaderStatus(sessions: readonly ISession[], reader: IRe } const status = session.status.read(reader); return status === SessionStatus.NeedsInput - || !session.isRead.read(reader) + || hasUnreadSessionListChat(session, reader) || (status !== SessionStatus.InProgress && !!session.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.pullRequest); }); } @@ -2796,9 +2812,13 @@ class SessionsAccessibilityProvider { if (diffStats) { label = localize('sessionChatItemChangesAria', "{0}, {1} lines added, {2} lines removed", label, diffStats.insertions, diffStats.deletions); } - return element.chat.isArchived.read(reader) - ? localize('sessionChatItemArchivedAria', "{0}, archived", label) + const isArchived = element.chat.isArchived.read(reader); + const readLabel = !isArchived && !element.chat.isRead.read(reader) + ? localize('sessionChatItemUnreadAria', "{0}, unread", label) : label; + return isArchived + ? localize('sessionChatItemArchivedAria', "{0}, archived", label) + : readLabel; }); } if (isSessionGroupItem(element)) { @@ -2879,15 +2899,19 @@ class SessionsAccessibilityProvider { } else { label = updatedAt ? localize('sessionItemAria', "{0}, updated {1}", title, fromNow(updatedAt, true)) : title; } + const collapsed = this.options?.collapsedSessionIds?.read(reader).has(element.sessionId) ?? true; const status = getSessionRowStatus( element, reader, !!this.options?.deriveStatusFromMainChat, - this.options?.collapsedSessionIds?.read(reader).has(element.sessionId) ?? true, + collapsed, ); if (this.options?.deriveStatusFromMainChat) { label = localize('sessionItemStatusAria', "{0}, {1}", label, getSessionConversationStatusAriaLabel(status)); } + if (!element.isArchived.read(reader) && !getSessionRowIsRead(element, reader, !!this.options?.deriveStatusFromMainChat)) { + label = localize('sessionItemUnreadAria', "{0}, unread", label); + } const inputNeededMessage = this.options ? getCompactInputNeededMessage(element, reader, this.options, this.options.approvalModel) : undefined; @@ -5190,7 +5214,8 @@ export class SessionsList extends Disposable implements ISessionsList { if (this.pendingOpenRequest !== request) { return false; } - if (this._sessionsService.activeSession.get()?.sessionId !== session.sessionId) { + const sessionRowRepresentsSession = getSessionListChats(session).length === 0 || this.collapsedSessionIds.get().has(session.sessionId); + if (!request.chat && sessionRowRepresentsSession && this._sessionsService.activeSession.get()?.sessionId !== session.sessionId) { this.markRead(session); } this.invokeOpenRequest(request); @@ -5457,7 +5482,7 @@ export class SessionsList extends Disposable implements ISessionsList { [IsSessionPinnedContext.key, this.isSessionPinned(element)], [SessionItemIsMultiSelectionContext.key, selectedSessions.length > 1], [SessionIsArchivedContext.key, element.isArchived.get()], - [SessionIsReadContext.key, element.isRead.get()], + [SessionIsReadContext.key, element.mainChat.get().isRead.get()], [SessionItemInGroupContext.key, inGroup], [SessionItemInExternalSectionContext.key, this.isRenderedInExternalSection(element)], [SessionItemCanImportContext.key, element.isExternal?.get() === true && element.capabilities.get().supportsImport === true], @@ -6607,7 +6632,7 @@ export class SessionsFlatList extends Disposable { const disposables = new DisposableStore(); const contextKeyService = this.contextKeyService.createOverlay([ [SessionIsArchivedContext.key, session.isArchived.get()], - [SessionIsReadContext.key, session.isRead.get()], + [SessionIsReadContext.key, session.mainChat.get().isRead.get()], [SessionTypeContext.key, session.sessionType], [SessionProviderIdContext.key, session.providerId], [SessionSupportsMultipleChatsContext.key, session.capabilities.get().supportsMultipleChats], diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 7a9513c75865..e1fb7b1baa39 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -1476,7 +1476,9 @@ registerAction2(class MarkSessionReadAction extends Action2 { } const sessions = Array.isArray(context) ? context : [context]; const sessionsManagementService = accessor.get(ISessionsManagementService); - sessionsManagementService.markAllRead(sessions); + for (const session of sessions) { + sessionsManagementService.markRead(session); + } } }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts index deccbe106433..a431baa03919 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts @@ -149,6 +149,7 @@ class FixtureSessionsManagementService extends mock( mainChat: constObservable(upcastPartial({ updatedAt, status, + isRead: constObservable(index !== 0), changes: constObservable([]), changesets: constObservable([]), })), diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts index 32dc8181ab3f..2ac1b977b9f9 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts @@ -447,6 +447,7 @@ class FakeSessionsManagementService extends mock() i mainChat: constObservable(upcastPartial({ updatedAt: constObservable(new Date()), status: this.sessionStatus, + isRead: this.isRead, changes: constObservable([]), changesets: constObservable([]), })), @@ -482,6 +483,7 @@ class FakeSessionsManagementService extends mock() i mainChat: constObservable(upcastPartial({ updatedAt: constObservable(new Date()), status: this.sessionStatus, + isRead: this.secondIsRead, changes: constObservable([]), changesets: constObservable([]), })), diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts index c8ddabf9b7a3..cbf46cef4571 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts @@ -258,6 +258,35 @@ suite('Sessions - Actions', () => { ]); }); + test('session row read actions target each selected main chat', async () => { + const instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + const sessions = [createTestSession('First').session, createTestSession('Second').session]; + const markedRead: string[] = []; + const markedUnread: string[] = []; + instantiationService.stub(ISessionsManagementService, new class extends mock() { + override async markRead(session: ISession): Promise { + markedRead.push(session.mainChat.get().resource.toString()); + } + override async markUnread(session: ISession): Promise { + markedUnread.push(session.mainChat.get().resource.toString()); + } + override async markAllRead(): Promise { + throw new Error('Session row action must not use the aggregate read operation'); + } + }); + + await CommandsRegistry.getCommand(MARK_SESSION_READ_COMMAND_ID)?.handler(instantiationService, sessions); + await CommandsRegistry.getCommand(MARK_SESSION_UNREAD_COMMAND_ID)?.handler(instantiationService, sessions); + + assert.deepStrictEqual({ + markedRead, + markedUnread, + }, { + markedRead: sessions.map(session => session.mainChat.get().resource.toString()), + markedUnread: sessions.map(session => session.mainChat.get().resource.toString()), + }); + }); + test('the main session context menu opens its main chat to the side', async () => { const instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); const { session } = createTestSession('Session'); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 7f268d1a06cf..d0b7f58cd0f2 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -108,7 +108,8 @@ function createSession(id: string, opts: { const createdAt = opts.createdAt ?? new Date(); const updatedAt = opts.updatedAt ?? createdAt; const isArchived = observableValue(`isArchived-${id}`, opts.isArchived ?? false); - const mainChat = upcastPartial({ updatedAt: constObservable(updatedAt), changes: constObservable([]), changesets: constObservable([]) }); + const isRead = observableValue(`isRead-${id}`, opts.isRead ?? true); + const mainChat = upcastPartial({ updatedAt: constObservable(updatedAt), isRead, changes: constObservable([]), changesets: constObservable([]) }); return { sessionId: id, resource: opts.resource ?? URI.parse(`session://${id}`), @@ -137,7 +138,7 @@ function createSession(id: string, opts: { mode: observableValue(`mode-${id}`, undefined), loading: observableValue(`loading-${id}`, false), isArchived, - isRead: observableValue(`isRead-${id}`, opts.isRead ?? true), + isRead, description: observableValue(`description-${id}`, undefined), lastTurnEnd: observableValue(`lastTurnEnd-${id}`, undefined), chats: observableValue(`chats-${id}`, []), @@ -1160,6 +1161,31 @@ suite('Sessions - SessionsList', () => { return header; } + test('derives collapsed section unread state from its chats', () => { + const built = buildTestSession({ + id: 'section-chat-read-state', + title: 'Main chat', + workspace: 'Workspace', + isRead: false, + mainChatIsRead: true, + chats: [{ id: 'peer', title: 'Peer chat', isRead: false }], + }); + const { list, container } = renderList([built.session]); + list.collapseAllSections(); + + const peerUnread = unreadSections(container); + built.chats.get('peer')?.isRead.set(true, undefined); + const chatsRead = unreadSections(container); + built.mainChat.isRead.set(false, undefined); + const mainUnread = unreadSections(container); + + assert.deepStrictEqual({ peerUnread, chatsRead, mainUnread }, { + peerUnread: ['Workspace'], + chatsRead: [], + mainUnread: ['Workspace'], + }); + }); + test('reports the experiment trigger where a header could show a status, whether or not the setting shows it', () => { const trigger = [`config.${SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING}`]; const withPullRequest = (session: ISession): ISession => { @@ -2413,7 +2439,7 @@ suite('Sessions - SessionsList', () => { title: constObservable('Fix the redirect loop'), isQuickChat: constObservable(false), worktreePending: constObservable(false), - mainChat: constObservable(upcastPartial({ updatedAt: constObservable(new Date()), changes: constObservable([]), changesets: constObservable([]) })), + mainChat: constObservable(upcastPartial({ updatedAt: constObservable(new Date()), isRead: constObservable(true), changes: constObservable([]), changesets: constObservable([]) })), workspace: constObservable({ uri: root, label: 'vscode', @@ -4148,6 +4174,7 @@ suite('Sessions - SessionsList', () => { status: constObservable(status), description: constObservable(undefined), isArchived: constObservable(false), + isRead: constObservable(true), changes: constObservable([]), changesets: constObservable([]), interactivity: constObservable(interactivity), @@ -4195,9 +4222,7 @@ suite('Sessions - SessionsList', () => { onChatOpen, })); list.layout(300, 400); - if (expandChats) { - setSessionChatsExpanded(container, true); - } + setSessionChatsExpanded(container, expandChats); return { container, list, managementService: harness.managementService }; } @@ -4209,6 +4234,83 @@ suite('Sessions - SessionsList', () => { return [...container.querySelectorAll('.session-chat-title')].map(element => element.textContent ?? ''); } + test('renders the exact main chat read state whether collapsed or expanded', () => { + const built = buildTestSession({ + id: 'chat-read-state', + title: 'Main chat', + isRead: false, + mainChatIsRead: true, + chats: [{ id: 'peer', title: 'Peer chat', isRead: false }], + }); + const { container } = renderSessionChatsList(built.session, undefined, false, false); + const mainItem = container.querySelector('.session-item'); + const snapshot = () => ({ + mainUnread: mainItem?.classList.contains('unread'), + mainAriaLabel: mainItem?.closest('.monaco-list-row')?.getAttribute('aria-label'), + peerUnread: container.querySelector('.session-chat-item')?.classList.contains('unread'), + peerAriaLabel: container.querySelector('.session-chat-item')?.closest('.monaco-list-row')?.getAttribute('aria-label'), + }); + + const collapsed = snapshot(); + setSessionChatsExpanded(container, true); + const expanded = snapshot(); + setSessionChatsExpanded(container, false); + const recollapsed = snapshot(); + built.mainChat.isRead.set(false, undefined); + const mainUnread = snapshot(); + built.chats.get('peer')?.isRead.set(true, undefined); + const peerRead = snapshot(); + built.isRead.set(true, undefined); + const sessionRead = snapshot(); + setSessionChatsExpanded(container, true); + const expandedMainUnread = snapshot(); + + assert.deepStrictEqual({ collapsed, expanded, recollapsed, mainUnread, peerRead, sessionRead, expandedMainUnread }, { + collapsed: { + mainUnread: false, + mainAriaLabel: 'Main chat, updated now, State: Completed', + peerUnread: undefined, + peerAriaLabel: undefined, + }, + expanded: { + mainUnread: false, + mainAriaLabel: 'Main chat, updated now, State: Completed', + peerUnread: true, + peerAriaLabel: 'Peer chat, chat, updated now, State: Completed, unread', + }, + recollapsed: { + mainUnread: false, + mainAriaLabel: 'Main chat, updated now, State: Completed', + peerUnread: undefined, + peerAriaLabel: undefined, + }, + mainUnread: { + mainUnread: true, + mainAriaLabel: 'Main chat, updated now, State: Completed, unread', + peerUnread: undefined, + peerAriaLabel: undefined, + }, + peerRead: { + mainUnread: true, + mainAriaLabel: 'Main chat, updated now, State: Completed, unread', + peerUnread: undefined, + peerAriaLabel: undefined, + }, + sessionRead: { + mainUnread: true, + mainAriaLabel: 'Main chat, updated now, State: Completed, unread', + peerUnread: undefined, + peerAriaLabel: undefined, + }, + expandedMainUnread: { + mainUnread: true, + mainAriaLabel: 'Main chat, updated now, State: Completed, unread', + peerUnread: false, + peerAriaLabel: 'Peer chat, chat, updated now, State: Completed', + }, + }); + }); + test('matches the main chat row vertical layout in compact and regular views', () => { const main = createChat('Main chat'); const peer = createChat('Peer chat', ChatOriginKind.User); @@ -4366,6 +4468,7 @@ suite('Sessions - SessionsList', () => { updatedAt: constObservable(new Date()), status, isArchived: constObservable(false), + isRead: constObservable(true), changes: constObservable([]), changesets: constObservable([]), interactivity: constObservable(ChatInteractivity.Full), @@ -5425,6 +5528,7 @@ suite('Sessions - SessionsList', () => { updatedAt: constObservable(new Date()), status: mainStatus, isArchived: constObservable(false), + isRead: constObservable(true), changes: constObservable([]), changesets: constObservable([]), interactivity: constObservable(ChatInteractivity.Full), @@ -5499,6 +5603,7 @@ suite('Sessions - SessionsList', () => { status: observableFromEvent(disposables, childStatusEmitter.event, () => SessionStatus.InProgress), description: constObservable(undefined), isArchived: constObservable(false), + isRead: constObservable(true), interactivity: constObservable(ChatInteractivity.Full), origin: { kind: ChatOriginKind.User }, }); @@ -5757,6 +5862,7 @@ suite('Sessions - SessionsList', () => { updatedAt: constObservable(new Date()), status: mainStatus, isArchived: constObservable(false), + isRead: constObservable(true), changes: constObservable([]), changesets: constObservable([]), interactivity: constObservable(ChatInteractivity.Full), @@ -6993,7 +7099,7 @@ suite('Sessions - SessionsList', () => { badge: undefined, time: undefined, hasDiff: false, - ariaLabel: 'Investigate failure, updated now', + ariaLabel: 'Investigate failure, updated now, unread', }, regular: { usesStandardRowHeight: true, @@ -7005,7 +7111,7 @@ suite('Sessions - SessionsList', () => { badge: 'No workspace', time: 'now', hasDiff: false, - ariaLabel: 'Investigate failure, chat, updated now', + ariaLabel: 'Investigate failure, chat, updated now, unread', }, }); }); @@ -7018,6 +7124,7 @@ suite('Sessions - SessionsList', () => { updatedAt: constObservable(new Date()), status: constObservable(SessionStatus.Completed), isArchived: constObservable(false), + isRead: constObservable(true), changes: constObservable([]), changesets: constObservable([]), interactivity: constObservable(ChatInteractivity.Full), @@ -7474,6 +7581,56 @@ suite('Sessions - SessionsList', () => { }); }); + test('opening an expanded main chat does not mark the parent session read', async () => { + const base = createTestSession('Unread multi-chat', { isRead: false }).session; + const main = base.mainChat.get(); + const peer: IChat = { + ...main, + resource: URI.parse('test-chat://peer'), + workspace: constObservable(undefined), + title: constObservable('Peer chat'), + updatedAt: constObservable(new Date()), + status: constObservable(SessionStatus.Completed), + isArchived: constObservable(false), + isRead: constObservable(true), + changes: constObservable([]), + changesets: constObservable([]), + interactivity: constObservable(ChatInteractivity.Full), + origin: { kind: ChatOriginKind.User }, + }; + const session: ISession = { + ...base, + chats: constObservable([main, peer]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const harness = createListHarness(disposables, [session]); + const container = harness.createContainer(); + const opened: string[] = []; + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: resource => { + opened.push(resource.toString()); + }, + })); + list.layout(300, 400); + const row = findSessionRow(container, 'Unread multi-chat'); + const twistie = row.querySelector('.session-chat-twistie'); + assert.ok(twistie); + if (row.getAttribute('aria-expanded') !== 'true') { + twistie.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); + } + + clickRow(row); + await settle(); + + assert.deepStrictEqual({ opened, markedRead: harness.managementService.readSessions.length }, { + opened: [session.resource.toString()], + markedRead: 0, + }); + }); + for (const flat of [false, true]) { for (const active of [false, true]) { test(`${flat ? 'flat' : 'main'} list ${active ? 'preserves an active session unread mark' : 'marks an inactive session read'} when opening a row`, async () => { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts index 29ad8ba0bd44..0bc660daad50 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts @@ -282,6 +282,7 @@ suite('Sessions list context menus', () => { description: constObservable(undefined), interactivity: constObservable(ChatInteractivity.Full), isArchived: constObservable(false), + isRead: constObservable(true), changes: constObservable([]), changesets: constObservable([]), }); @@ -294,6 +295,7 @@ suite('Sessions list context menus', () => { description: constObservable(undefined), interactivity: constObservable(ChatInteractivity.Full), isArchived: constObservable(false), + isRead: constObservable(true), capabilities: constObservable({ canRename: true, canArchive: true, canDelete: true }), changes: constObservable([]), changesets: constObservable([]), diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts index a957e7714122..ccbe33806e72 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts @@ -433,11 +433,11 @@ class FixtureSessionsManagementService extends mock( } override async markRead(session: ISession): Promise { - this.find(session.resource)?.isRead.set(true, undefined); + this.find(session.resource)?.mainChat.isRead.set(true, undefined); } override async markUnread(session: ISession): Promise { - this.find(session.resource)?.isRead.set(false, undefined); + this.find(session.resource)?.mainChat.isRead.set(false, undefined); } override async markAllRead(sessions: readonly ISession[]): Promise { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts index 404bf9c0fc3c..34a000b31c2b 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts @@ -147,16 +147,17 @@ export function createTestSession(title: string, options: ITestSessionOptions = const resource = URI.parse(`test-session://${resourceId}`); const capabilities = observableValue(`capabilities-${resourceId}`, { supportsMultipleChats: false, supportsRename: true }); const status = observableValue(`status-${resourceId}`, options.status ?? SessionStatus.Completed); + const isRead = observableValue(`read-${resourceId}`, options.isRead ?? true); const mainChat = new class extends mock() { override readonly resource = resource.with({ fragment: 'main' }); override readonly updatedAt = constObservable(now); override readonly status = status; + override readonly isRead = isRead; override readonly description = constObservable(undefined); override readonly changes = constObservable([]); override readonly changesets = constObservable([]); }(); const isArchived = observableValue(`archived-${resourceId}`, options.isArchived ?? false); - const isRead = observableValue(`read-${resourceId}`, options.isRead ?? true); const isExternal = observableValue(`external-${resourceId}`, options.isExternal ?? false); const workspaceLabel = options.workspaceLabel ?? 'Workspace'; const isQuickChat = options.isQuickChat ?? false; diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts index eb648b36d251..edcf8c7dc074 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts @@ -163,6 +163,7 @@ suite('Sessions rename', () => { override readonly description = constObservable(undefined); override readonly interactivity = constObservable(ChatInteractivity.Full); override readonly isArchived = constObservable(false); + override readonly isRead = constObservable(true); override readonly capabilities = constObservable({ canRename: true, canArchive: true, canDelete: true }); }(); const chatSession: ISession = { @@ -384,6 +385,7 @@ suite('Sessions rename', () => { override readonly description = constObservable(undefined); override readonly interactivity = constObservable(ChatInteractivity.Full); override readonly isArchived = constObservable(false); + override readonly isRead = constObservable(true); override readonly capabilities = constObservable({ canRename: true, canArchive: true, canDelete: true }); }(); const chats = observableValue('renameDraftChats', [mainChat, peerChat]); @@ -461,6 +463,7 @@ suite('Sessions rename', () => { override readonly description = constObservable(undefined); override readonly interactivity = constObservable(ChatInteractivity.Full); override readonly isArchived = constObservable(false); + override readonly isRead = constObservable(true); override readonly capabilities = constObservable({ canRename: true, canArchive: true, canDelete: true }); }(); const session: ISession = { @@ -646,6 +649,7 @@ suite('Sessions rename', () => { override readonly description = constObservable(undefined); override readonly interactivity = constObservable(ChatInteractivity.Full); override readonly isArchived = constObservable(false); + override readonly isRead = constObservable(true); override readonly capabilities = constObservable({ canRename: options.canRename ?? true, canArchive: true, canDelete: true }); }(); const otherPeerChat = new class extends mock() { @@ -656,6 +660,7 @@ suite('Sessions rename', () => { override readonly description = constObservable(undefined); override readonly interactivity = constObservable(ChatInteractivity.Full); override readonly isArchived = constObservable(false); + override readonly isRead = constObservable(true); override readonly capabilities = constObservable({ canRename: true, canArchive: true, canDelete: true }); }(); const chats = observableValue('renameChats', [mainChat, peerChat, otherPeerChat]); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 7c2c19f2f4c9..17e0a744b95d 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -9,7 +9,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; import { toErrorMessage } from '../../../../base/common/errorMessage.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { ResourceMap, ResourceSet } from '../../../../base/common/map.js'; +import { ResourceMap } from '../../../../base/common/map.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -68,6 +68,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; + private readonly _explicitlyMarkedUnreadMainChats = new ResourceMap(); private readonly _onDidDiscardNewSession = this._register(new Emitter()); readonly onDidDiscardNewSession: Event = this._onDidDiscardNewSession.event; @@ -88,8 +89,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private readonly _disposeCts = this._register(new CancellationTokenSource()); private readonly _unlistedNewSessions = new ResourceMap(); private readonly _inFlightNewSessionRequests = new ResourceMap<{ readonly session: ISession; readonly input?: Pick; readonly published: boolean; count: number }>(); - private readonly _explicitlyMarkedUnreadSessions = new ResourceSet(); - /** * Chat resources for which this service has just kicked off a * `provider.sendRequest` and will emit `_onDidSendRequest` manually after @@ -177,6 +176,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } private _handleDidReplaceSession(from: ISession, to: ISession): void { + this._explicitlyMarkedUnreadMainChats.delete(from.resource); this.chatWidgetHistoryService.moveHistory(ChatAgentLocation.Chat, from.sessionId, to.sessionId); // Notify the view service so it can update the visible grid slot. this._onDidReplaceSession.fire({ from, to }); @@ -1325,24 +1325,61 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } async setSessionReadState(session: ISession, isRead: boolean): Promise { - // Record intent before the provider can synchronously notify active-session observers. if (isRead) { - this._explicitlyMarkedUnreadSessions.delete(session.resource); - } else { - this._explicitlyMarkedUnreadSessions.add(session.resource); + this._explicitlyMarkedUnreadMainChats.delete(session.resource); } await this._getProvider(session)?.setSessionReadState(session.sessionId, isRead); } - markRead(session: ISession, options?: IMarkSessionReadOptions): Promise { - if (options?.preserveExplicitUnread && this._explicitlyMarkedUnreadSessions.has(session.resource)) { - return Promise.resolve(); - } - return this.setSessionReadState(session, true); + async markChatRead(session: ISession, chat: IChat): Promise { + return this._getProvider(session)?.setChatReadState?.(session.sessionId, chat.resource, true) ?? false; } - markUnread(session: ISession): Promise { - return this.setSessionReadState(session, false); + markRead(session: ISession, options?: IMarkSessionReadOptions): Promise { + if (options?.preserveExplicitUnread + && this._explicitlyMarkedUnreadMainChats.has(session.resource) + && this._explicitlyMarkedUnreadMainChats.get(session.resource) === this._getMainChatReadMarker(session)) { + return Promise.resolve(); + } + return this._setMainChatReadState(session, true); + } + + private _getMainChatReadMarker(session: ISession): number | undefined { + const chat = session.mainChat.get(); + return (chat.lastTurnEnd.get() ?? chat.updatedAt.get())?.getTime(); + } + + async markUnread(session: ISession): Promise { + const provider = this._getProvider(session); + if (!provider) { + return; + } + const hadExplicitUnread = this._explicitlyMarkedUnreadMainChats.has(session.resource); + const previousMarker = this._explicitlyMarkedUnreadMainChats.get(session.resource); + this._explicitlyMarkedUnreadMainChats.set(session.resource, this._getMainChatReadMarker(session)); + try { + await this._setMainChatReadState(session, false, provider); + } catch (error) { + if (hadExplicitUnread) { + this._explicitlyMarkedUnreadMainChats.set(session.resource, previousMarker); + } else { + this._explicitlyMarkedUnreadMainChats.delete(session.resource); + } + throw error; + } + } + + private _setMainChatReadState(session: ISession, isRead: boolean, provider = this._getProvider(session)): Promise { + if (isRead) { + this._explicitlyMarkedUnreadMainChats.delete(session.resource); + } + if (!provider) { + return Promise.resolve(false); + } + const mainChat = session.mainChat.get(); + return provider.setChatReadState + ? provider.setChatReadState(session.sessionId, mainChat.resource, isRead) + : provider.setSessionReadState(session.sessionId, isRead); } async markAllRead(sessions: readonly ISession[]): Promise { @@ -1351,7 +1388,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa async deleteSession(session: ISession): Promise { await this._getProvider(session)?.deleteSession(session.sessionId); - this._explicitlyMarkedUnreadSessions.delete(session.resource); + this._explicitlyMarkedUnreadMainChats.delete(session.resource); this._onDidDeleteSession.fire(session); } @@ -1375,7 +1412,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa try { await provider.deleteSessions(providerSessions.map(session => session.sessionId)); for (const session of providerSessions) { - this._explicitlyMarkedUnreadSessions.delete(session.resource); + this._explicitlyMarkedUnreadMainChats.delete(session.resource); this._onDidDeleteSession.fire(session); } } catch (error) { diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 04c87c4a26f2..b933ad270357 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -406,6 +406,7 @@ export class SessionsService extends Disposable implements ISessionsService { private readonly _sessionStates: ResourceMap; private readonly _pendingRestoredChatResources = new ResourceMap(); + private readonly _pendingExplicitChatOpens = new ResourceMap<{ readonly chatResource: URI; readonly token: CancellationToken }>(); private readonly _navigation: SessionsNavigation; /** * The single source of truth for session recency (most-recently-opened @@ -510,18 +511,6 @@ export class SessionsService extends Disposable implements ISessionsService { } })); - // Honor explicit unread marks until the user leaves the session and returns. - let previousActiveSessionId: string | undefined; - this._register(autorun(reader => { - const activeSession = this.activeSession.read(reader); - const isRead = activeSession?.isRead.read(reader); - const activeSessionChanged = activeSession?.sessionId !== previousActiveSessionId; - previousActiveSessionId = activeSession?.sessionId; - if (activeSession && (activeSessionChanged || !isRead)) { - this.sessionsManagementService.markRead(activeSession, { preserveExplicitUnread: !activeSessionChanged }).catch(onUnexpectedError); - } - })); - // Reflect provider-level session changes onto the grid: drop removed // sessions and pick a fallback (or the new-session view) when the active // one disappears. @@ -667,6 +656,53 @@ export class SessionsService extends Disposable implements ISessionsService { } })); + let previousActiveChat: URI | undefined; + let lastReadRequest: { readonly chat: URI; readonly version: number | undefined } | undefined; + disposables.add(autorun(reader => { + const chat = activeSession.activeChat.read(reader); + const mainChat = activeSession.mainChat.read(reader); + const activeChatChanged = !chat || !previousActiveChat || !this.uriIdentityService.extUri.isEqual(chat.resource, previousActiveChat); + previousActiveChat = chat?.resource; + if (activeChatChanged) { + lastReadRequest = undefined; + } + if (activeSession.loading.read(reader)) { + lastReadRequest = undefined; + return; + } + if (!chat || chat.isRead.read(reader)) { + return; + } + const pendingExplicitChat = this._pendingExplicitChatOpens.get(activeSession.resource)?.chatResource; + if (pendingExplicitChat && !this.uriIdentityService.extUri.isEqual(chat.resource, pendingExplicitChat)) { + return; + } + const version = (chat.lastTurnEnd?.read(reader) ?? chat.updatedAt.read(reader))?.getTime(); + // Deduplicate stale read-state echoes without suppressing the next completed turn in the active chat. + if (lastReadRequest + && this.uriIdentityService.extUri.isEqual(lastReadRequest.chat, chat.resource) + && lastReadRequest.version === version) { + return; + } + const readRequest = { chat: chat.resource, version }; + lastReadRequest = readRequest; + const accepted = this.uriIdentityService.extUri.isEqual(chat.resource, mainChat.resource) + ? this.sessionsManagementService.markRead(activeSession, { + preserveExplicitUnread: !activeChatChanged, + }) + : this.sessionsManagementService.markChatRead(activeSession, chat); + accepted.then(result => { + if (result === false && lastReadRequest === readRequest) { + lastReadRequest = undefined; + } + }, error => { + if (lastReadRequest === readRequest) { + lastReadRequest = undefined; + } + onUnexpectedError(error); + }); + })); + return disposables; } @@ -835,41 +871,49 @@ export class SessionsService extends Disposable implements ISessionsService { } private async _openChat(session: ISession, chatUri: URI, preserveFocus: boolean | undefined, token: CancellationToken, startTime: number, telemetryAttempt?: ISessionOpenTelemetryAttempt): Promise { - if (telemetryAttempt) { - this.sessionOpenTelemetryService.sessionActivated(telemetryAttempt, chatUri); - } - this.logService.trace(`[SessionsView] openChat start uri=${chatUri.toString()} provider=${session.providerId}`); - this._activate(session, preserveFocus); - if (!await this._waitForSessionToLoad(session, token)) { - this.logService.trace(`[SessionsView] openChat cancelled while waiting for session to load uri=${chatUri.toString()}`); - return; - } + const pendingOpen = { chatResource: chatUri, token }; + this._pendingExplicitChatOpens.set(session.resource, pendingOpen); + try { + if (telemetryAttempt) { + this.sessionOpenTelemetryService.sessionActivated(telemetryAttempt, chatUri); + } + this.logService.trace(`[SessionsView] openChat start uri=${chatUri.toString()} provider=${session.providerId}`); + this._activate(session, preserveFocus); + if (!await this._waitForSessionToLoad(session, token)) { + this.logService.trace(`[SessionsView] openChat cancelled while waiting for session to load uri=${chatUri.toString()}`); + return; + } - // Find the chat and update active chat - let chat: IChat | undefined; - const activeSession = this._visibility.activeSession.get(); - if (activeSession) { - chat = activeSession.chats.get().find(c => this.uriIdentityService.extUri.isEqual(c.resource, chatUri)); - if (chat) { - // Opening a chat also un-hides it if it was previously closed. - this._visibility.openChat(session, chat); - this._visibility.setActiveChat(session, chat); - this._setChatVisibilityState(session, chat, true); + // Find the chat and update active chat + let chat: IChat | undefined; + const activeSession = this._visibility.activeSession.get(); + if (activeSession) { + chat = activeSession.chats.get().find(c => this.uriIdentityService.extUri.isEqual(c.resource, chatUri)); + if (chat) { + // Opening a chat also un-hides it if it was previously closed. + this._visibility.openChat(session, chat); + this._visibility.setActiveChat(session, chat); + this._setChatVisibilityState(session, chat, true); + } + } + if (telemetryAttempt) { + if (chat) { + this.sessionOpenTelemetryService.sessionActivated(telemetryAttempt, chat.resource); + } + this.sessionOpenTelemetryService.sessionLoaded(telemetryAttempt); + } + + if (chat && chat.status.get() === SessionStatus.Untitled) { + this.logService.trace(`[SessionsView] openChat done total=${Date.now() - startTime}ms uri=${chatUri.toString()} path=untitled`); + return; + } + + this.logService.trace(`[SessionsView] openChat done total=${Date.now() - startTime}ms uri=${chatUri.toString()}`); + } finally { + if (this._pendingExplicitChatOpens.get(session.resource) === pendingOpen) { + this._pendingExplicitChatOpens.delete(session.resource); } } - if (telemetryAttempt) { - if (chat) { - this.sessionOpenTelemetryService.sessionActivated(telemetryAttempt, chat.resource); - } - this.sessionOpenTelemetryService.sessionLoaded(telemetryAttempt); - } - - if (chat && chat.status.get() === SessionStatus.Untitled) { - this.logService.trace(`[SessionsView] openChat done total=${Date.now() - startTime}ms uri=${chatUri.toString()} path=untitled`); - return; - } - - this.logService.trace(`[SessionsView] openChat done total=${Date.now() - startTime}ms uri=${chatUri.toString()}`); } async closeChat(session: IActiveSession, chat: IChat, options?: ICloseChatOptions): Promise { diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index efe5b8356ce2..e4c70b17f9ee 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -158,7 +158,7 @@ function setSessionContextKeysUnbuffered(session: ISession | undefined, contextK keys.type.set(session?.sessionType ?? ''); keys.isArchived.set(session?.isArchived.read(reader) ?? false); keys.isActive.set(session ? isActiveSessionStatus(session.status.read(reader)) : false); - keys.isRead.set(session?.isRead.read(reader) ?? true); + keys.isRead.set(session?.mainChat.read(reader).isRead.read(reader) ?? true); const capabilities = session?.capabilities.read(reader); keys.supportsMultipleChats.set(capabilities?.supportsMultipleChats ?? false); keys.supportsFork.set(capabilities?.supportsFork ?? false); diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 1e0e909ca659..d19d225bce6e 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -147,11 +147,6 @@ export interface ICreateNewChatInSessionOptions { readonly forceNew?: boolean; } -export interface IMarkSessionReadOptions { - /** Keep an explicit unread mark during automatic updates within the current visit. */ - readonly preserveExplicitUnread?: boolean; -} - /** * Event fired when sessions change within a provider. */ @@ -238,11 +233,12 @@ export interface IRecentlyOpenedSessions { readonly other: ISession[]; } -/** - * An active session item extends IChatSessionItem with repository information. - * - For agent session items: repository is the workingDirectory from metadata - * - For new sessions: repository comes from the session option with id 'repository' - */ +/** Controls automatic read transitions for an active main chat. */ +export interface IMarkSessionReadOptions { + /** Preserve a user-requested unread state until the active chat advances. */ + readonly preserveExplicitUnread?: boolean; +} + export interface ISessionsManagementService { readonly _serviceBrand: undefined; @@ -577,10 +573,13 @@ export interface ISessionsManagementService { */ setSessionReadState(session: ISession, isRead: boolean): Promise; - /** Mark a session as read through its provider. */ - markRead(session: ISession, options?: IMarkSessionReadOptions): Promise; + /** Mark a chat as read through its provider when it supports independent chat read state. */ + markChatRead(session: ISession, chat: IChat): Promise; - /** Mark a session as unread through its provider. */ + /** Mark the session's main chat as read through its provider. */ + markRead(session: ISession, options?: IMarkSessionReadOptions): Promise; + + /** Mark the session's main chat as unread through its provider. */ markUnread(session: ISession): Promise; /** Mark all of the given sessions as read through their providers. */ diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index d413a6866566..288f3c150607 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -520,6 +520,13 @@ export interface ISessionsProvider { */ setSessionReadState(sessionId: string, isRead: boolean): Promise; + /** + * Set the read/unread state of a chat independently of its owning session. + * Providers without independently readable chats leave this capability undefined. + * Returns `false` when the transition was not accepted and may be retried. + */ + setChatReadState?(sessionId: string, chatResource: URI, isRead: boolean): Promise; + /** * Delete a session. * @param sessionId The ID of the session to delete. diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 46bbb74050ed..ef45bfad92db 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -255,6 +255,7 @@ class MockSessionStore implements ISessionsManagementService { archiveChat(_session: ISession, _chat: IChat): Promise { throw new Error('not implemented'); } unarchiveChat(_session: ISession, _chat: IChat): Promise { throw new Error('not implemented'); } setSessionReadState(_session: ISession, _isRead: boolean): Promise { throw new Error('not implemented'); } + markChatRead(_session: ISession, _chat: IChat): Promise { throw new Error('not implemented'); } markRead(_session: ISession): Promise { throw new Error('not implemented'); } markUnread(_session: ISession): Promise { throw new Error('not implemented'); } markAllRead(_sessions: readonly ISession[]): Promise { throw new Error('not implemented'); } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 6cd0087e9087..8239c142d74c 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -1354,17 +1354,244 @@ suite('SessionsManagementService', () => { return { ...createSessionsManagementService(session, disposables, provider), session, other, isRead, status, readChanges }; } - test('marks the active session as read via its provider even when its provider state was unread', async () => { - const { session, view } = createReadStateSessions(); + test('opening a session leaves its parent state unchanged when the main chat is already read', async () => { + const { session, view, readChanges } = createReadStateSessions(); const readBeforeActive = session.isRead.get(); await view.openSession(session.resource); assert.deepStrictEqual( - { readBeforeActive, readWhileActive: session.isRead.get(), activeId: view.activeSession.get()?.sessionId }, - { readBeforeActive: false, readWhileActive: true, activeId: 'unread' }, + { readBeforeActive, readWhileActive: session.isRead.get(), activeId: view.activeSession.get()?.sessionId, readChanges }, + { readBeforeActive: false, readWhileActive: false, activeId: 'unread', readChanges: [] }, ); }); + test('preserves an explicit unread active single chat until the chat advances', async () => { + const sessionIsRead = observableValue('sessionIsRead', false); + const mainIsRead = observableValue('mainIsRead', true); + const lastTurnEnd = observableValue('lastTurnEnd', new Date(1)); + const main = { ...stubChat, isRead: mainIsRead, lastTurnEnd }; + const session = stubSession({ + sessionId: 'explicit-unread', + providerId: 'test', + isRead: sessionIsRead, + mainChat: constObservable(main), + chats: constObservable([main]), + }); + const readChanges: { readonly chat: string; readonly isRead: boolean }[] = []; + const provider = new class extends TestSessionsProvider { + override async setChatReadState(_sessionId: string, chatResource: URI, isRead: boolean): Promise { + readChanges.push({ chat: chatResource.toString(), isRead }); + mainIsRead.set(isRead, undefined); + } + }(session); + const { service, view } = createSessionsManagementService(session, disposables, provider); + await view.openSession(session.resource); + + await service.markUnread(session); + const afterExplicitUnread = mainIsRead.get(); + lastTurnEnd.set(new Date(2), undefined); + + assert.deepStrictEqual({ + afterExplicitUnread, + afterNewTurn: mainIsRead.get(), + sessionIsRead: sessionIsRead.get(), + readChanges, + }, { + afterExplicitUnread: false, + afterNewTurn: true, + sessionIsRead: false, + readChanges: [ + { chat: 'test:/chat', isRead: false }, + { chat: 'test:/chat', isRead: true }, + ], + }); + }); + + test('marks the active main chat as read once without changing the parent or peer', async () => { + const mainIsRead = observableValue('mainIsRead', false); + const main = { ...stubChat, resource: URI.parse('test:///main'), isRead: mainIsRead }; + const peer = { ...stubChat, resource: URI.parse('test:///peer') }; + const session = stubSession({ + sessionId: 'multi-chat', + providerId: 'test', + isRead: constObservable(false), + mainChat: constObservable(main), + chats: constObservable([main, peer]), + }); + const readChanges: { sessionId: string; chat: string; isRead: boolean }[] = []; + const provider = new class extends TestSessionsProvider { + override async setChatReadState(sessionId: string, chatResource: URI, isRead: boolean): Promise { + if (mainIsRead.get() !== isRead) { + mainIsRead.set(isRead, undefined); + readChanges.push({ sessionId, chat: chatResource.toString(), isRead }); + } + } + }(session); + const { view } = createSessionsManagementService(session, disposables, provider); + + await view.openSession(session.resource); + const mainIsReadAfterOpen = mainIsRead.get(); + mainIsRead.set(false, undefined); + + assert.deepStrictEqual({ + mainIsReadAfterOpen, + mainIsReadAfterStaleEcho: mainIsRead.get(), + sessionIsRead: session.isRead.get(), + peerIsRead: peer.isRead.get(), + readChanges, + }, { + mainIsReadAfterOpen: true, + mainIsReadAfterStaleEcho: false, + sessionIsRead: false, + peerIsRead: true, + readChanges: [{ sessionId: 'multi-chat', chat: 'test:/main', isRead: true }], + }); + }); + + test('retries marking the active chat read after provider loading completes', async () => { + const mainIsRead = observableValue('mainIsRead', false); + const loading = observableValue('loading', false); + const main = { ...stubChat, resource: URI.parse('test:///main'), isRead: mainIsRead }; + const session = stubSession({ + sessionId: 'startup-read-retry', + providerId: 'test', + loading, + mainChat: constObservable(main), + chats: constObservable([main]), + }); + const attempts: boolean[] = []; + let ready = false; + const provider = new class extends TestSessionsProvider { + override async setChatReadState(_sessionId: string, _chatResource: URI, isRead: boolean): Promise { + attempts.push(ready); + if (!ready) { + return false; + } + mainIsRead.set(isRead, undefined); + return true; + } + }(session); + const { view } = createSessionsManagementService(session, disposables, provider); + + await view.openSession(session.resource); + await timeout(0); + const afterRejectedAttempt = mainIsRead.get(); + loading.set(true, undefined); + ready = true; + loading.set(false, undefined); + await timeout(0); + + assert.deepStrictEqual({ + afterRejectedAttempt, + afterReady: mainIsRead.get(), + attempts, + }, { + afterRejectedAttempt: false, + afterReady: true, + attempts: [false, true], + }); + }); + + test('uses the main chat read operation when only a tool peer accompanies it', async () => { + const sessionIsRead = observableValue('sessionIsRead', false); + const mainIsRead = observableValue('mainIsRead', false); + const main = { ...stubChat, resource: URI.parse('test:///main'), isRead: mainIsRead }; + const toolIsRead = observableValue('toolIsRead', false); + const tool = { + ...stubChat, + resource: URI.parse('test:///tool'), + isRead: toolIsRead, + origin: { kind: ChatOriginKind.Tool }, + interactivity: constObservable(ChatInteractivity.ReadOnly), + }; + const session = stubSession({ + sessionId: 'tool-peer', + providerId: 'test', + isRead: sessionIsRead, + mainChat: constObservable(main), + chats: constObservable([main, tool]), + }); + const readChanges: string[] = []; + const provider = new class extends TestSessionsProvider { + override async setChatReadState(_sessionId: string, chatResource: URI, read: boolean): Promise { + readChanges.push(`chat:${chatResource.toString()}:${read}`); + if (chatResource.toString() === main.resource.toString()) { + mainIsRead.set(read, undefined); + } + } + }(session); + const { view } = createSessionsManagementService(session, disposables, provider); + + await view.openSession(session.resource); + + assert.deepStrictEqual({ + session: session.isRead.get(), + main: main.isRead.get(), + tool: tool.isRead.get(), + readChanges, + }, { + session: false, + main: true, + tool: false, + readChanges: ['chat:test:/main:true'], + }); + }); + + test('marks the active peer chat as read once through its provider', async () => { + const mainIsRead = observableValue('mainIsRead', false); + const main = { ...stubChat, resource: URI.parse('test:///main'), isRead: mainIsRead }; + const peerIsRead = observableValue('peerIsRead', false); + const peerUpdatedAt = observableValue('peerUpdatedAt', new Date(1)); + const peer = { ...stubChat, resource: URI.parse('test:///peer'), isRead: peerIsRead, updatedAt: peerUpdatedAt }; + const session = stubSession({ + sessionId: 'multi-chat', + providerId: 'test', + mainChat: constObservable(main), + chats: constObservable([main, peer]), + }); + const readChanges: { sessionId: string; chat: string; isRead: boolean }[] = []; + const provider = new class extends TestSessionsProvider { + override async setChatReadState(sessionId: string, chatResource: URI, isRead: boolean): Promise { + if (chatResource.toString() === peer.resource.toString()) { + peerIsRead.set(isRead, undefined); + } else if (chatResource.toString() === main.resource.toString()) { + mainIsRead.set(isRead, undefined); + } + readChanges.push({ sessionId, chat: chatResource.toString(), isRead }); + } + }(session); + const { view } = createSessionsManagementService(session, disposables, provider); + + await view.openChat(session, peer.resource); + const peerIsReadAfterOpen = peerIsRead.get(); + peerIsRead.set(false, undefined); + const peerIsReadAfterStaleEcho = peerIsRead.get(); + peerUpdatedAt.set(new Date(2), undefined); + const peerIsReadAfterNewTurn = peerIsRead.get(); + peerIsRead.set(false, undefined); + + assert.deepStrictEqual({ + activeChat: view.activeSession.get()?.activeChat.get().resource.toString(), + mainIsRead: mainIsRead.get(), + peerIsReadAfterOpen, + peerIsReadAfterStaleEcho, + peerIsReadAfterNewTurn, + peerIsReadAfterRepeatedEcho: peerIsRead.get(), + readChanges, + }, { + activeChat: 'test:/peer', + mainIsRead: false, + peerIsReadAfterOpen: true, + peerIsReadAfterStaleEcho: false, + peerIsReadAfterNewTurn: true, + peerIsReadAfterRepeatedEcho: false, + readChanges: [ + { sessionId: 'multi-chat', chat: 'test:/peer', isRead: true }, + { sessionId: 'multi-chat', chat: 'test:/peer', isRead: true }, + ], + }); + }); + test('leaves a non-active session in its provider read state', async () => { const { session, other, view, readChanges } = createReadStateSessions(); await view.openSession(other.resource); @@ -1375,65 +1602,50 @@ suite('SessionsManagementService', () => { ); }); - for (const destination of ['another session', 'the new-session composer']) { - test(`keeps an explicitly unread active session unread until navigating to ${destination} and back`, async () => { - const { session, other, service, view, readChanges } = createReadStateSessions(); - await view.openSession(session.resource); - await service.markUnread(session); - const afterMarkUnread = session.isRead.get(); - - await view.openSession(session.resource); - const afterReopeningActive = session.isRead.get(); - - if (destination === 'another session') { - await view.openSession(other.resource); - } else { - await view.openNewSession(); - } - const afterLeaving = session.isRead.get(); - await view.openSession(session.resource); - - assert.deepStrictEqual( - { afterMarkUnread, afterReopeningActive, afterLeaving, afterReturning: session.isRead.get(), readChanges }, - { afterMarkUnread: false, afterReopeningActive: false, afterLeaving: false, afterReturning: true, readChanges: [true, false, true] }, - ); + test('explicit session row read actions update the main chat only', async () => { + const sessionIsRead = observableValue('sessionIsRead', false); + const mainIsRead = observableValue('mainIsRead', true); + const main = { ...stubChat, isRead: mainIsRead }; + const session = stubSession({ + sessionId: 'explicit-main-chat-read', + providerId: 'test', + isRead: sessionIsRead, + mainChat: constObservable(main), + chats: constObservable([main]), }); - } - - test('automatically reads active-session updates without overriding an explicit unread mark', async () => { - const { session, service, view, isRead, status, readChanges } = createReadStateSessions(); - await view.openSession(session.resource); - isRead.set(false, undefined); - const afterProviderUpdate = isRead.get(); + const chatReadChanges: boolean[] = []; + const sessionReadChanges: boolean[] = []; + const provider = new class extends TestSessionsProvider { + override async setChatReadState(_sessionId: string, _chatResource: URI, isRead: boolean): Promise { + chatReadChanges.push(isRead); + mainIsRead.set(isRead, undefined); + } + override async setSessionReadState(_sessionId: string, isRead: boolean): Promise { + sessionReadChanges.push(isRead); + sessionIsRead.set(isRead, undefined); + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); await service.markUnread(session); - status.set(SessionStatus.InProgress, undefined); - status.set(SessionStatus.Completed, undefined); + const afterMarkUnread = { main: mainIsRead.get(), session: sessionIsRead.get() }; + await service.markRead(session); assert.deepStrictEqual( - { afterProviderUpdate, afterTurn: isRead.get(), readChanges }, - { afterProviderUpdate: true, afterTurn: false, readChanges: [true, true, false] }, + { + afterMarkUnread, + afterMarkRead: { main: mainIsRead.get(), session: sessionIsRead.get() }, + chatReadChanges, + sessionReadChanges, + }, + { + afterMarkUnread: { main: false, session: false }, + afterMarkRead: { main: true, session: false }, + chatReadChanges: [false, true], + sessionReadChanges: [], + }, ); }); - - for (const all of [false, true]) { - test(`${all ? 'marking all sessions read' : 'marking the session read'} resumes automatic reading`, async () => { - const { session, service, view, isRead, readChanges } = createReadStateSessions(); - await view.openSession(session.resource); - await service.markUnread(session); - if (all) { - await service.markAllRead([session]); - } else { - await service.markRead(session); - } - isRead.set(false, undefined); - - assert.deepStrictEqual( - { isRead: isRead.get(), readChanges }, - { isRead: true, readChanges: [true, false, true, true] }, - ); - }); - } }); test('archiving the active session keeps the custom view open', async () => { diff --git a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts index fdb3cfd2e21e..4b7fee94fbfb 100644 --- a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts +++ b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts @@ -13,7 +13,7 @@ import { ContextKeyValue, IContextKey } from '../../../../../platform/contextkey import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; import { IChatSessionFileChange } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { SessionActiveChatCanArchiveContext, SessionActiveChatHasSideChatsContext, SessionActiveChatResourceContext, SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasGitRepositoryContext, SessionHasMultipleCommittedChatsContext, SessionHasWorkspaceContext, SessionIsActiveContext, SessionIsCreatedContext, SessionProviderIdContext, SessionSupportsSideChatContext, SessionWorkspaceIsVirtualContext } from '../../../../common/contextkeys.js'; +import { SessionActiveChatCanArchiveContext, SessionActiveChatHasSideChatsContext, SessionActiveChatResourceContext, SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasGitRepositoryContext, SessionHasMultipleCommittedChatsContext, SessionHasWorkspaceContext, SessionIsActiveContext, SessionIsCreatedContext, SessionIsReadContext, SessionProviderIdContext, SessionSupportsSideChatContext, SessionWorkspaceIsVirtualContext } from '../../../../common/contextkeys.js'; import { ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionChangeset, ISessionWorkspace, SessionStatus } from '../../common/session.js'; import { IActiveSession } from '../../common/sessionsManagement.js'; import { setActiveSessionContextKeys, setSessionContextKeys } from '../../common/sessionContextKeys.js'; @@ -138,6 +138,34 @@ suite('Session Context Keys', () => { }); }); + test('publishes the main chat read state independently of the session aggregate', () => { + const contextKeyService = store.add(new MockContextKeyService()); + const sessionIsRead = observableValue('sessionIsRead', false); + const mainChatIsRead = observableValue('mainChatIsRead', true); + const mainChat = { ...stubChat, isRead: mainChatIsRead }; + const session = stubSession({ + sessionId: 'a', + isRead: sessionIsRead, + mainChat: constObservable(mainChat), + }); + + store.add(autorun(reader => setSessionContextKeys(session, contextKeyService, reader))); + const initially = SessionIsReadContext.getValue(contextKeyService); + mainChatIsRead.set(false, undefined); + const afterMainChatUnread = SessionIsReadContext.getValue(contextKeyService); + sessionIsRead.set(true, undefined); + + assert.deepStrictEqual({ + initially, + afterMainChatUnread, + afterSessionRead: SessionIsReadContext.getValue(contextKeyService), + }, { + initially: true, + afterMainChatUnread: false, + afterSessionRead: false, + }); + }); + test('publishes workspace keys from the active chat', () => { const contextKeyService = store.add(new MockContextKeyService()); const sessionWorkspace = { @@ -354,6 +382,7 @@ suite('setSessionContextKeys - changes', () => { workspace: constObservable(upcastPartial({ folders: [] })), changesets: constObservable([]), changes: chatChanges, + isRead: constObservable(true), }); const session = upcastPartial({ ...stubSession({ diff --git a/src/vs/sessions/services/sessions/test/common/testSessionBuilder.ts b/src/vs/sessions/services/sessions/test/common/testSessionBuilder.ts index 17ccd2036859..a4d53bf6ade4 100644 --- a/src/vs/sessions/services/sessions/test/common/testSessionBuilder.ts +++ b/src/vs/sessions/services/sessions/test/common/testSessionBuilder.ts @@ -23,6 +23,7 @@ export interface ITestChatSpec { readonly hasModifiedTime?: boolean; readonly interactivity?: ChatInteractivity; readonly isArchived?: boolean; + readonly isRead?: boolean; /** Whether the chat can be archived on its own; defaults to `false`, like {@link DEFAULT_CHAT_CAPABILITIES}. */ readonly canArchive?: boolean; } @@ -46,6 +47,8 @@ export interface ITestSessionSpec { readonly mainChatMinutesAgo?: number; /** Whether the main chat's modified time is resolved; defaults to `true`. */ readonly mainChatHasModifiedTime?: boolean; + /** Read state of the main chat when it differs from the session read state. */ + readonly mainChatIsRead?: boolean; /** Status description rendered as markdown. */ readonly description?: string; /** Minutes since the session was last updated. */ @@ -69,6 +72,7 @@ export interface ITestChat { readonly status: ISettableObservable; readonly updatedAt: ISettableObservable; readonly isArchived: ISettableObservable; + readonly isRead: ISettableObservable; } /** A session built from a {@link ITestSessionSpec}, with handles to change its state. */ @@ -112,6 +116,7 @@ function buildTestChat(resource: URI, spec: Omit, createdAt const status = observableValue('testChatStatus', spec.status ?? SessionStatus.Completed); const updatedAtObservable = observableValue('testChatUpdatedAt', updatedAt); const isArchived = observableValue('testChatIsArchived', spec.isArchived ?? false); + const isRead = observableValue('testChatIsRead', spec.isRead ?? true); const chat: IChat = { resource, createdAt, @@ -126,13 +131,13 @@ function buildTestChat(resource: URI, spec: Omit, createdAt modelSource: constObservable(undefined), mode: constObservable(undefined), isArchived, - isRead: constObservable(true), + isRead, interactivity: constObservable(spec.interactivity ?? ChatInteractivity.Full), description: constObservable(undefined), lastTurnEnd: constObservable(undefined), capabilities: constObservable({ ...DEFAULT_CHAT_CAPABILITIES, canArchive: spec.canArchive ?? DEFAULT_CHAT_CAPABILITIES.canArchive }), }; - return { chat, title, status, updatedAt: updatedAtObservable, isArchived }; + return { chat, title, status, updatedAt: updatedAtObservable, isArchived, isRead }; } /** Builds a complete {@link ISession}, with times relative to `now`. */ @@ -141,7 +146,7 @@ export function buildTestSession(spec: ITestSessionSpec, now: number = Date.now( const createdAt = new Date(now - (spec.createdMinutesAgo ?? spec.minutesAgo ?? 0) * 60_000); const status = observableValue('testSessionStatus', spec.status ?? SessionStatus.Completed); const mainChatUpdatedAt = spec.mainChatHasModifiedTime === false ? undefined : new Date(now - (spec.mainChatMinutesAgo ?? spec.minutesAgo ?? 0) * 60_000); - const mainChat = buildTestChat(getTestChatResource(spec.id, 'main'), { title: spec.title, status: spec.mainChatStatus ?? spec.status }, createdAt, mainChatUpdatedAt); + const mainChat = buildTestChat(getTestChatResource(spec.id, 'main'), { title: spec.title, status: spec.mainChatStatus ?? spec.status, isRead: spec.mainChatIsRead ?? spec.isRead }, createdAt, mainChatUpdatedAt); const chats = new Map((spec.chats ?? []).map(chat => { const chatUpdatedAt = chat.hasModifiedTime === false ? undefined : new Date(now - (chat.minutesAgo ?? spec.minutesAgo ?? 0) * 60_000); return [chat.id, buildTestChat(getTestChatResource(spec.id, chat.id), chat, createdAt, chatUpdatedAt)] as const; diff --git a/src/vs/sessions/test/browser/chatCompositeBar.test.ts b/src/vs/sessions/test/browser/chatCompositeBar.test.ts index 50d5243d764e..6c365d7138d3 100644 --- a/src/vs/sessions/test/browser/chatCompositeBar.test.ts +++ b/src/vs/sessions/test/browser/chatCompositeBar.test.ts @@ -122,14 +122,14 @@ class TestEditorGroupsService extends mock() { } } -function createChat(id: string, title: string, status: SessionStatus = SessionStatus.Completed, options?: { readonly isArchived?: boolean; readonly canArchive?: boolean }): IChat { +function createChat(id: string, title: string, status: SessionStatus = SessionStatus.Completed, options?: { readonly isArchived?: boolean; readonly isRead?: boolean; readonly canArchive?: boolean }): IChat { const resource = URI.parse(`test-chat://${id}`); return new class extends mock() { override readonly resource = resource; override readonly title: IObservable = constObservable(title); override readonly status: IObservable = constObservable(status); override readonly isArchived: IObservable = constObservable(options?.isArchived ?? false); - override readonly isRead: IObservable = constObservable(true); + override readonly isRead: IObservable = constObservable(options?.isRead ?? true); override readonly interactivity: IObservable = constObservable(ChatInteractivity.Full); override readonly capabilities = constObservable({ canRename: true, canArchive: options?.canArchive ?? false, canDelete: true }); }(); @@ -172,15 +172,15 @@ interface IChatCompositeBarHarness { readonly showSessionActions: ISettableObservable; } -function createHarness(disposables: Pick, options?: { readonly isAgentHost?: boolean; readonly isQuickChat?: boolean; readonly resizeObserverCtor?: typeof ResizeObserver; readonly secondaryArchived?: boolean; readonly secondaryCanArchive?: boolean }): IChatCompositeBarHarness { +function createHarness(disposables: Pick, options?: { readonly isAgentHost?: boolean; readonly isQuickChat?: boolean; readonly resizeObserverCtor?: typeof ResizeObserver; readonly mainIsRead?: boolean; readonly secondaryIsRead?: boolean; readonly secondaryArchived?: boolean; readonly secondaryCanArchive?: boolean }): IChatCompositeBarHarness { const store = disposables.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, store); const commandService = new TestCommandService(); const contextMenuService = new TestContextMenuService(); const sessionsService = new TestSessionsService(); const editorGroupsService = store.add(new TestEditorGroupsService()); - const mainChat = createChat('main', 'Main Chat'); - const secondaryChat = createChat('secondary', 'Secondary Chat', SessionStatus.Completed, { isArchived: options?.secondaryArchived, canArchive: options?.secondaryCanArchive }); + const mainChat = createChat('main', 'Main Chat', SessionStatus.Completed, { isRead: options?.mainIsRead }); + const secondaryChat = createChat('secondary', 'Secondary Chat', SessionStatus.Completed, { isArchived: options?.secondaryArchived, isRead: options?.secondaryIsRead, canArchive: options?.secondaryCanArchive }); const session = createSession([mainChat, secondaryChat], mainChat, options?.isQuickChat); const chats = observableValue('test.chats', [mainChat, secondaryChat]); const activeChatResource = observableValue('test.activeChatResource', mainChat.resource.toString()); @@ -635,6 +635,29 @@ suite('Sessions - ChatCompositeBar', () => { }); }); + test('renders unread state from each chat', () => { + const { tabs, activeChatResource } = createHarness(disposables, { mainIsRead: false, secondaryIsRead: false }); + const snapshot = () => tabs.map(tab => ({ + unread: tab.classList.contains('unread'), + ariaLabel: tab.getAttribute('aria-label'), + })); + + const mainActive = snapshot(); + activeChatResource.set('test-chat://secondary', undefined); + const secondaryActive = snapshot(); + + assert.deepStrictEqual({ mainActive, secondaryActive }, { + mainActive: [ + { unread: false, ariaLabel: 'Main Chat, State: Completed, unread' }, + { unread: true, ariaLabel: 'Secondary Chat, State: Completed, unread' }, + ], + secondaryActive: [ + { unread: true, ariaLabel: 'Main Chat, State: Completed, unread' }, + { unread: false, ariaLabel: 'Secondary Chat, State: Completed, unread' }, + ], + }); + }); + test('arrow, Home, and End keys move focus and activate chat tabs', () => { const { bar, container, sessionsService, tabs } = createHarness(disposables); mainWindow.document.body.appendChild(container); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts index 4ba52d75e5ef..1f199f5615e3 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts @@ -124,6 +124,7 @@ function createBlockedSession(options: IBlockedSessionOptions, approvals?: Map = constObservable(updatedAt); override readonly status: IObservable = constObservable(options.status); + override readonly isRead: IObservable = constObservable(true); override readonly changes: IObservable = constObservable([]); override readonly changesets: IObservable = constObservable([]); }(); From 14516e10af25a36bd6fb69ccb7c8746ab13adb52 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Sat, 3 Oct 2026 21:51:21 -0700 Subject: [PATCH 05/49] Handle indexed search telemetry in Agent Host (#339534) agentHost: handle indexed search telemetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/copilot/copilotAgentSession.ts | 7 ++ .../copilotGitHubTelemetryForwarder.ts | 44 +++++++++ .../node/copilot/copilotSessionWrapper.ts | 5 + .../test/node/copilotAgentSession.test.ts | 22 +++++ .../copilotGitHubTelemetryForwarder.test.ts | 97 +++++++++++++++++++ 5 files changed, 175 insertions(+) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 841e9e06685b..f7f57f0a7ebf 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -8418,6 +8418,13 @@ export class CopilotAgentSession extends Disposable { this._logService.trace(`[Copilot:${sessionId}] Unhandled SDK event: ${safeStringify(loggedEvent)}`); })); + this._register(wrapper.onIndexedSearch(e => { + const data = e.data.kind === 'startup' || e.data.kind === 'server_error' + ? (({ errorMessage: _, ...data }) => data)(e.data) + : e.data; + this._logService.trace(`[Copilot:${sessionId}] session.indexed_search: ${safeStringify(data)}`); + })); + this._register(wrapper.onExtensionsLoaded(() => { if (wrapper.canvasRuntimeEnabled) { this._canvasProjectionReady = true; diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts index 2d11efcd53f4..4548df067b80 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts @@ -209,6 +209,7 @@ type ModelCallTurnCorrelatedClassification = { "arguments": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Telemetry-safe tool arguments; unsafe values are hashed by the runtime." }, "result_type": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Coarse tool result outcome, either SUCCESS or FAILURE." }, "invoke_outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Fine-grained invocation outcome: success, error, cancelled, or disabledByUser." }, + "search_engine": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Search implementation used by the grep tool, either ripgrep or tgrep." }, "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Model that requested the tool call." }, "tool_call_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier for this tool call, stable across correlated events." }, "turn_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier for the agent loop turn the tool was invoked in." }, @@ -230,6 +231,49 @@ type ModelCallTurnCorrelatedClassification = { } */ +/* __GDPR__ + "copilotSdk/tgrep_startup": { + "owner": "bhavyaus", + "comment": "Reports the outcome and performance of an indexed-search startup attempt in the Copilot CLI runtime.", + "${include}": [ "${CopilotSdkForwardedTelemetry}" ], + "outcome": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Bounded outcome of the indexed-search startup attempt." }, + "forced_by_env": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether indexed search was explicitly enabled through the environment." }, + "warm_start": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the startup attempted to wait for index readiness." }, + "disabled_reason": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Bounded configuration, policy, or workspace condition that disabled indexed search." }, + "eligible": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the repository met the automatic indexing file-count threshold." }, + "error_message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Indexed-search startup failure details, emitted only as restricted telemetry." }, + "file_count": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of text files counted in the repository.", "isMeasurement": true }, + "startup_duration_ms": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Wall-clock duration of indexed-search startup in milliseconds.", "isMeasurement": true } + } +*/ + +/* __GDPR__ + "copilotSdk/tgrep_server_error": { + "owner": "bhavyaus", + "comment": "Reports failures of the Copilot CLI indexed-search server.", + "${include}": [ "${CopilotSdkForwardedTelemetry}" ], + "error_type": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Bounded category of the indexed-search server failure." }, + "error_message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Indexed-search server failure details, emitted only as restricted telemetry." }, + "exit_code": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Indexed-search server process exit code when available.", "isMeasurement": true } + } +*/ + +/* __GDPR__ + "copilotSdk/tgrep_incremental_indexing": { + "owner": "bhavyaus", + "comment": "Reports aggregate file counts and performance for incremental Copilot CLI indexed-search updates.", + "${include}": [ "${CopilotSdkForwardedTelemetry}" ], + "phase": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether workspace changes were detected or the incremental index update completed." }, + "changed_file_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of modified files detected during the incremental update.", "isMeasurement": true }, + "added_file_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of added files detected during the incremental update.", "isMeasurement": true }, + "deleted_file_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of deleted files detected during the incremental update.", "isMeasurement": true }, + "total_change_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Total number of changes detected during the incremental update.", "isMeasurement": true }, + "walk_duration_ms": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Workspace scan duration in milliseconds.", "isMeasurement": true }, + "update_duration_ms": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Index update duration in milliseconds.", "isMeasurement": true }, + "total_duration_ms": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Total incremental indexing duration in milliseconds.", "isMeasurement": true } + } +*/ + /* __GDPR__ "copilotSdk/hydrafusion_route": { "owner": "amunger", diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts index 056f64a14416..00bbdc8358e1 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts @@ -230,6 +230,11 @@ export class CopilotSessionWrapper extends Disposable { return this._onSessionInfo ??= this._sdkEvent('session.info'); } + private _onIndexedSearch: Event> | undefined; + get onIndexedSearch(): Event> { + return this._onIndexedSearch ??= this._sdkEvent('session.indexed_search'); + } + private _onSessionWarning: Event> | undefined; get onSessionWarning(): Event> { return this._onSessionWarning ??= this._sdkEvent('session.warning'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 946764c4c841..677e0ecd1041 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -3102,6 +3102,28 @@ suite('CopilotAgentSession', () => { ); }); + test('logs indexed search events without sensitive error details', async () => { + const logService = new CapturingLogService(); + const { mockSession } = await createAgentSession(disposables, { logService }); + + mockSession.fire('session.indexed_search', { + kind: 'startup', + outcome: 'failed', + startupDurationMs: 42, + forcedByEnv: false, + warmStart: true, + errorMessage: '/private/repository failed to index', + }); + + assert.deepStrictEqual({ + indexedSearch: logService.traces.filter(t => t.message.includes('session.indexed_search')).map(t => t.message), + unhandled: logService.traces.filter(t => t.message.includes('Unhandled SDK event')).map(t => t.message), + }, { + indexedSearch: ['[Copilot:test-session-1] session.indexed_search: {"kind":"startup","outcome":"failed","startupDurationMs":42,"forcedByEnv":false,"warmStart":true}'], + unhandled: [], + }); + }); + test('logs only envelope metadata for unknown Fusion events', async () => { const logService = new CapturingLogService(); const { mockSession, signals } = await createAgentSession(disposables, { logService }); diff --git a/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts b/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts index cf0869083c4c..bf5c6eab20cc 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts @@ -256,6 +256,7 @@ suite('CopilotGitHubTelemetryForwarder', () => { tool_name: 'grep', result_type: 'SUCCESS', invoke_outcome: 'success', + search_engine: 'tgrep', model: 'gpt-5.5', tool_call_id: 'call-1', }, @@ -273,6 +274,102 @@ suite('CopilotGitHubTelemetryForwarder', () => { assert.strictEqual(event.data?.result_token_count, 34); assert.strictEqual(event.data?.duration_ms, 12); assert.strictEqual(event.data?.tool_call_id, 'call-1'); + assert.strictEqual(event.data?.search_engine, 'tgrep'); + }); + + test('forwards indexed search telemetry and gates restricted errors', () => { + const telemetryService = new TestTelemetryService(); + let restrictedTelemetryEnabled = false; + const forwarder = new CopilotGitHubTelemetryForwarder(() => restrictedTelemetryEnabled, telemetryService); + + forwarder.forward({ + sessionId: 'session', + restricted: false, + event: { + kind: 'tgrep_startup', + properties: { outcome: 'started', forced_by_env: 'false', warm_start: 'true', eligible: 'true' }, + metrics: { file_count: 50_000, startup_duration_ms: 120 }, + }, + }); + forwarder.forward({ + sessionId: 'session', + restricted: false, + event: { + kind: 'tgrep_incremental_indexing', + properties: { phase: 'updated' }, + metrics: { changed_file_count: 2, total_change_count: 2, total_duration_ms: 15 }, + }, + }); + const serverError: GitHubTelemetryNotification = { + sessionId: 'session', + restricted: true, + event: { + kind: 'tgrep_server_error', + properties: { error_type: 'unexpected_exit', error_message: '/private/repository failed' }, + metrics: { exit_code: 1 }, + }, + }; + forwarder.forward(serverError); + restrictedTelemetryEnabled = true; + forwarder.forward(serverError); + + assert.deepStrictEqual(telemetryService.events.map(event => ({ + eventName: event.eventName, + data: event.data, + })), [ + { + eventName: 'copilotSdk/tgrep_startup', + data: { + outcome: 'started', + forced_by_env: 'false', + warm_start: 'true', + eligible: 'true', + file_count: 50_000, + startup_duration_ms: 120, + created_at: undefined, + model_call_id: undefined, + exp_assignment_context: undefined, + session_id: 'session', + sdk_session_id: 'session', + copilot_tracking_id: undefined, + kind: 'tgrep_startup', + restricted: false, + }, + }, + { + eventName: 'copilotSdk/tgrep_incremental_indexing', + data: { + phase: 'updated', + changed_file_count: 2, + total_change_count: 2, + total_duration_ms: 15, + created_at: undefined, + model_call_id: undefined, + exp_assignment_context: undefined, + session_id: 'session', + sdk_session_id: 'session', + copilot_tracking_id: undefined, + kind: 'tgrep_incremental_indexing', + restricted: false, + }, + }, + { + eventName: 'copilotSdk/tgrep_server_error', + data: { + error_type: 'unexpected_exit', + error_message: '/private/repository failed', + exit_code: 1, + created_at: undefined, + model_call_id: undefined, + exp_assignment_context: undefined, + session_id: 'session', + sdk_session_id: 'session', + copilot_tracking_id: undefined, + kind: 'tgrep_server_error', + restricted: true, + }, + }, + ]); }); test('only accepts host correlation diagnostics on response events', () => { From 7b968b17e4dc8f5ab4870c2ceae6aeef2ac63735 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 3 Oct 2026 22:00:20 -0700 Subject: [PATCH 06/49] github: add reusable repository queries (#339531) Related microsoft/vscode-internalbacklog#9678. Add reusable one-shot repository metadata and explicitly paged repository listing/search to the shared GitHub domain, using neutral account/repository references. Retain server-provided HTTPS/SSH clone URLs and expose search totals, incomplete results and the 1,000-result ceiling. Validate pagination before dispatch; fetch only one page per call so query scoping, selection and operation budgets remain caller-owned. Reuse existing credential/account checks, endpoint routing, governed REST transport, private ETags, request sharing and cancellation. Add 24 real-engine/fake-transport contract cases and update the source-adjacent README/client inventory with concrete consumer evidence. --- src/vs/platform/github/README.md | 10 + src/vs/platform/github/client-inventory.md | 11 +- .../github/common/githubQueryService.ts | 33 +++ .../github/common/githubQueryServiceImpl.ts | 112 +++++++- .../test/node/githubQueryService.test.ts | 262 ++++++++++++++++++ 5 files changed, 425 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/github/README.md b/src/vs/platform/github/README.md index 8e0f63b7e0c7..f676c717ede9 100644 --- a/src/vs/platform/github/README.md +++ b/src/vs/platform/github/README.md @@ -60,6 +60,16 @@ VS Code forwards optional account provenance through the standard authentication Private caches and request sharing remain token/base-specific. Host-supplied account provenance affects only quota accounting: known IDs share applicable GitHub account limits, while unknown identities use a conservative origin-wide bootstrap bucket. Known bootstrap clients also honor outstanding unresolved-origin waits. A bootstrap waiter fails promptly as rate-limited when the cooldown cannot fit its deadline; authenticated repository and anonymous reads keep their existing deadline behavior. Client release preserves live server cooldowns and cannot cancel another token's work. Migrating Copilot discovery to this capability is a separate, deferred change. +### Repository queries + +Authorized clients expose one-shot `getRepository`, `listRepositories` and `searchRepositories` operations in addition to repository subscriptions. They use the existing repository response mapping and governed REST transport, including authorization-scoped ETags and coalescing with subscribed reads. REST results retain server-provided HTTPS and SSH clone URLs when available; GraphQL-hydrated resources may omit them. + +Discovery takes an account handle, not a synthetic repository or Agent Host/session context. Each call fetches one page (1–100 items, default 100) and returns an optional `nextPage` from the server's pagination signal. Callers own iteration, selection, sorting, query qualifiers and operation budgets; the service does not fetch organizations, scope a search to the current user, or automatically drain every page. Empty queries and invalid pagination fail with a validation error before credential resolution or network dispatch. + +Search also returns `totalCount`, `incompleteResults` and `limitReached`. The last field identifies searches exceeding GitHub's 1,000 retrievable results; pagination never advertises a page beyond that ceiling. A missing `nextPage` does not imply a complete search when either flag is set. Malformed completeness fields are errors, not empty successful results. + +These contracts cover metadata/discovery requirements of core repository presentations, the legacy Sessions repository picker, and the built-in GitHub remote-source provider without taking over their UI, account selection or git workflows. Those legacy renderer/extension consumers remain unmigrated; shared-process authenticated-client IPC and repository creation/fork/branch APIs are separate work. + ### Agent Host repository and PR operations The [association resolver](../agentHost/node/agentHostPullRequestAssociationResolver.ts), [creation handler](../agentHost/node/agentHostPullRequestOperationHandler.ts) and [title controller](../agentHost/node/agentHostSessionTitleController.ts) retain an authorized client for each operation. They reuse the binding's selected repository resource and existing silent missing-token checks; title context does not introduce another scope requirement or a sign-in prompt. diff --git a/src/vs/platform/github/client-inventory.md b/src/vs/platform/github/client-inventory.md index 3abdf29d1900..5fc7fe1ac2c9 100644 --- a/src/vs/platform/github/client-inventory.md +++ b/src/vs/platform/github/client-inventory.md @@ -127,7 +127,16 @@ It already composes: 1. **Identity:** the engine supports explicit provider/session/scope/endpoint clients and credential-independent anonymous public JSON reads, with shared quota coordination and isolated private resources. Current workbench features retain default-account selection in their binding. Extension consent, Copilot anonymous/device token issuance, additional issuers and CAPI-specific bootstrap credentials still need integration. 2. **Transport shapes:** the current contract is primarily JSON plus bounded text download, not yet the general binary, multipart, SSE/WebSocket and response-metadata contract these clients require. -3. **Operation coverage:** existing typed APIs cover much of PR work, but not everything. Repository discovery/search and pending-review creation, for example, need additional coverage or governed REST/GraphQL access. +3. **Operation coverage:** typed repository metadata, accessible-repository listing and repository search are available alongside the existing PR APIs. Discovery is explicitly paged and preserves incomplete-search and result-ceiling metadata; query scoping and selection remain caller-owned. Repository creation/fork/branch operations and pending-review creation still need additional coverage or governed REST/GraphQL access. 4. **Aggregate protection:** per-engine bounded admission, caller fairness, request deadlines and response limits are implemented. Credential invalidation preserves live cooldowns and reclaims expired inactive-account state. Request-rate budgets, aggregate pagination/fan-out budgets, live subscription caps and cross-window coordination remain to be implemented. The key boundary is to **consolidate request execution, protection and shared resources**, not entire feature services or the generic HTTP service used for unrelated destinations. + +### Repository contract follow-up + +The follow-up was checked against VS Code `5983cda0c8126417e5a9cdf33d0be2f4000d3f04` after the merged Agent Host migration (#338992), and the read-only Pull Requests extension checkout `596a9bbd4a36c482cbe1faa862fa8e4ec5a8a345`. + +- Core link presentations already consume repository/issue/PR resources directly. The shared-process binding remains anonymous-only; authenticated relocation is not part of these domain additions. +- The [Sessions repository fetcher](../../sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts) needs repository metadata, accessible-repository pages and qualified search. The [built-in remote-source provider](../../../../extensions/github/src/remoteSourceProvider.ts) additionally needs server-provided HTTPS/SSH clone URLs. The [Pull Requests extension metadata reader](https://github.com/microsoft/vscode-pull-request-github/blob/596a9bbd4a36c482cbe1faa862fa8e4ec5a8a345/src/github/githubRepository.ts#L414) uses repository identity and the default branch. +- `getRepository`, `listRepositories` and `searchRepositories` address those proven gaps without a new client facade, token handling, independent cache or polling. The legacy consumers above are contract evidence, not newly migrated clients. +- Existing branch/SHA association, PR creation, repository merge settings and auto-merge already use the shared domain. Their fork/ambiguity handling, caller-approved selection, metadata, cancellation and no-replay creation behavior are unchanged. diff --git a/src/vs/platform/github/common/githubQueryService.ts b/src/vs/platform/github/common/githubQueryService.ts index 2fd50d851282..8e6b460b3611 100644 --- a/src/vs/platform/github/common/githubQueryService.ts +++ b/src/vs/platform/github/common/githubQueryService.ts @@ -37,10 +37,40 @@ export interface GitHubRepository { readonly private: boolean; readonly description: string; readonly url: string; + readonly cloneUrl?: string; + readonly sshUrl?: string; readonly archived: boolean; readonly fork: boolean; } +export interface GitHubRepositoryPageOptions { + readonly page?: number; + readonly perPage?: number; +} + +export interface GitHubRepositoryListOptions extends GitHubRepositoryPageOptions { + readonly affiliation?: readonly ('owner' | 'collaborator' | 'organization_member')[]; + readonly sort?: 'created' | 'updated' | 'pushed' | 'full_name'; + readonly direction?: 'asc' | 'desc'; +} + +export interface GitHubRepositorySearchOptions extends GitHubRepositoryPageOptions { + readonly sort?: 'stars' | 'forks' | 'help-wanted-issues' | 'updated'; + readonly direction?: 'asc' | 'desc'; +} + +export interface GitHubRepositoriesPage { + readonly repositories: readonly GitHubRepository[]; + readonly nextPage?: number; +} + +export interface GitHubRepositorySearchPage extends GitHubRepositoriesPage { + readonly totalCount: number; + readonly incompleteResults: boolean; + /** Whether GitHub's 1,000-result ceiling prevents retrieving all matches. */ + readonly limitReached: boolean; +} + export interface GitHubRepositoryMergeCapabilities { readonly autoMergeAllowed: boolean; readonly mergeMethods: readonly PullRequestMergeMethod[]; @@ -241,6 +271,9 @@ export interface GitHubQueryApi { subscribeIssue(ref: GitHubIssueRef, options: GitHubResourceSubscriptionOptions): GitHubIssueSubscription; subscribeCommit(ref: GitHubCommitRef, options: GitHubResourceSubscriptionOptions): GitHubCommitSubscription; hydrateResources(refs: readonly GitHubHydratableResourceRef[], signal: AbortSignal): Promise; + getRepository(ref: GitHubRepositoryRef, signal: AbortSignal): Promise; + listRepositories(account: AccountHandle, signal: AbortSignal, options?: GitHubRepositoryListOptions): Promise; + searchRepositories(account: AccountHandle, query: string, signal: AbortSignal, options?: GitHubRepositorySearchOptions): Promise; compare(ref: GitHubRepositoryRef, base: string, head: string, signal: AbortSignal): Promise; listPullRequests(ref: GitHubRepositoryRef, cursor: string | undefined, signal: AbortSignal): Promise; listPullRequestsWaitingForReview(ref: GitHubRepositoryRef, signal: AbortSignal): Promise; diff --git a/src/vs/platform/github/common/githubQueryServiceImpl.ts b/src/vs/platform/github/common/githubQueryServiceImpl.ts index df2391392541..2808201cb932 100644 --- a/src/vs/platform/github/common/githubQueryServiceImpl.ts +++ b/src/vs/platform/github/common/githubQueryServiceImpl.ts @@ -33,10 +33,15 @@ import { GitHubRecentIssue, GitHubRecentPullRequest, GitHubRecentPullRequestReviewThread, + GitHubRepositoriesPage, GitHubRepository, + GitHubRepositoryListOptions, GitHubRepositoryMergeCapabilities, + GitHubRepositoryPageOptions, GitHubRepositoryRef, GitHubRepositoryResource, + GitHubRepositorySearchOptions, + GitHubRepositorySearchPage, GitHubRepositorySubscription, GitHubResourcePriority, GitHubResourceSubscriptionOptions, @@ -51,6 +56,7 @@ import { BackoffPolicy, backoffDelay } from './backoff.js'; import { IGitHubEndpointProvider } from './githubTypes.js'; import { getPullRequestUrlKey } from './githubUrls.js'; import { PullRequestScheduler } from './pullRequestScheduler.js'; +import { AccountHandle } from './types.js'; export interface IGitHubQuery extends GitHubQueryApi { clear(): void; @@ -75,6 +81,8 @@ const defaultPollingPolicy: GitHubEntityPollingPolicy = { }; const maximumPaginationPages = 100; +const maximumRepositoryPageSize = 100; +const maximumRepositorySearchResults = 1_000; const maximumHydrationBatchSize = 25; const repositoryHydrationFields = ` id @@ -494,6 +502,84 @@ export class GitHubQueryService extends Disposable implements IGitHubQuery { return subscription; } + async getRepository(ref: GitHubRepositoryRef, signal: AbortSignal): Promise { + const normalized = normalizeRepositoryRef(ref); + return this._withCredential(normalized, signal, async (credential, combinedSignal) => { + const response = await this._transport.rest(credential.account, credential.token, { + caller: 'github.query', + method: 'GET', + url: this._restUrl(normalized, ''), + etag: true, + priority: 'interactive', + }, combinedSignal); + return toRepository(response.data); + }); + } + + async listRepositories(account: AccountHandle, signal: AbortSignal, options?: GitHubRepositoryListOptions): Promise { + const { page, perPage } = repositoryPagination(options); + const params = new URLSearchParams({ page: String(page), per_page: String(perPage) }); + if (options?.affiliation) { + if (options.affiliation.length === 0) { + throw new GitHubRequestError('GitHub repository listing requires at least one affiliation', 'validation'); + } + params.set('affiliation', [...new Set(options.affiliation)].join(',')); + } + if (options?.sort) { + params.set('sort', options.sort); + } + if (options?.direction) { + params.set('direction', options.direction); + } + return this._withCredential(account, signal, async (credential, combinedSignal) => { + const response = await this._transport.rest(credential.account, credential.token, { + caller: 'github.query', + method: 'GET', + url: `${this._endpoint.getApiBaseUri()}/user/repos?${params}`, + etag: true, + priority: 'interactive', + }, combinedSignal); + return toRepositoriesPage(response.data, response.link, page, perPage); + }); + } + + async searchRepositories(account: AccountHandle, query: string, signal: AbortSignal, options?: GitHubRepositorySearchOptions): Promise { + const { page, perPage } = repositoryPagination(options); + if (!query.trim() || (page - 1) * perPage >= maximumRepositorySearchResults) { + throw new GitHubRequestError('GitHub repository search requires a query and a page within the first 1,000 results', 'validation'); + } + const params = new URLSearchParams({ q: query.trim(), page: String(page), per_page: String(perPage) }); + if (options?.sort) { + params.set('sort', options.sort); + } + if (options?.direction) { + params.set('order', options.direction); + } + return this._withCredential(account, signal, async (credential, combinedSignal) => { + const response = await this._transport.rest(credential.account, credential.token, { + caller: 'github.query', + method: 'GET', + url: `${this._endpoint.getApiBaseUri()}/search/repositories?${params}`, + etag: true, + priority: 'interactive', + }, combinedSignal); + const value = asObject(response.data, 'GitHub repository search response was malformed'); + const totalCount = requiredNumber(value, 'total_count'); + const incompleteResults = booleanProperty(value, 'incomplete_results'); + if (!Number.isSafeInteger(totalCount) || totalCount < 0 || incompleteResults === undefined) { + throw new GitHubRequestError('GitHub repository search completeness was malformed', 'malformedResponse'); + } + const result = toRepositoriesPage(arrayProperty(value, 'items'), response.link, page, perPage); + return { + ...result, + nextPage: page * perPage < maximumRepositorySearchResults ? result.nextPage : undefined, + totalCount, + incompleteResults, + limitReached: totalCount > maximumRepositorySearchResults, + }; + }); + } + async compare(ref: GitHubRepositoryRef, base: string, head: string, signal: AbortSignal): Promise { const normalized = normalizeRepositoryRef(ref); if (!base || !head) { @@ -1071,7 +1157,7 @@ export class GitHubQueryService extends Disposable implements IGitHubQuery { } private async _withCredential( - ref: GitHubRepositoryRef, + ref: AccountHandle, signal: AbortSignal, task: (credential: GitHubCredential, signal: AbortSignal) => Promise, ): Promise { @@ -1257,7 +1343,7 @@ function entityKey(kind: EntityKind, ref: EntityRef): string { } function sameAccount( - ref: GitHubRepositoryRef, + ref: AccountHandle, credential: { readonly account: { readonly host: string; readonly accountId: string } }, ): boolean { return ref.host.toLowerCase() === credential.account.host.toLowerCase() && ref.accountId === credential.account.accountId; @@ -1267,11 +1353,31 @@ function toRequestPriority(priority: GitHubResourcePriority): 'interactive' | 'v return priority; } +function repositoryPagination(options: GitHubRepositoryPageOptions | undefined): { page: number; perPage: number } { + const page = options?.page ?? 1; + const perPage = options?.perPage ?? maximumRepositoryPageSize; + if (!Number.isSafeInteger(page) || page <= 0 || !Number.isSafeInteger(perPage) || perPage <= 0 || perPage > maximumRepositoryPageSize) { + throw new GitHubRequestError('GitHub repository pagination requires a positive page and a page size between 1 and 100', 'validation'); + } + return { page, perPage }; +} + +function toRepositoriesPage(value: unknown, link: string | undefined, page: number, perPage: number): GitHubRepositoriesPage { + const values = asArray(value, 'GitHub repository page was malformed'); + const nextPage = nextLink(link) ? page + 1 : undefined; + if (values.length > perPage || (nextPage !== undefined && !Number.isSafeInteger(nextPage))) { + throw new GitHubRequestError('GitHub repository pagination was malformed', 'malformedResponse'); + } + return { repositories: values.map(toRepository), nextPage }; +} + function toRepository(value: unknown): GitHubRepository { const item = asObject(value, 'GitHub repository response was malformed'); const owner = objectProperty(item, 'owner'); const language = nullableStringProperty(item, 'language'); const stars = numberProperty(item, 'stargazers_count'); + const cloneUrl = stringProperty(item, 'clone_url'); + const sshUrl = stringProperty(item, 'ssh_url'); return { id: idProperty(item, 'node_id') ?? idProperty(item, 'id'), owner: requiredActor(owner), @@ -1283,6 +1389,8 @@ function toRepository(value: unknown): GitHubRepository { private: booleanProperty(item, 'private') ?? false, description: nullableStringProperty(item, 'description') ?? '', url: requiredString(item, 'html_url'), + ...(cloneUrl !== undefined ? { cloneUrl } : {}), + ...(sshUrl !== undefined ? { sshUrl } : {}), archived: booleanProperty(item, 'archived') ?? false, fork: booleanProperty(item, 'fork') ?? false, }; diff --git a/src/vs/platform/github/test/node/githubQueryService.test.ts b/src/vs/platform/github/test/node/githubQueryService.test.ts index 63962d579136..e9ea6b595edf 100644 --- a/src/vs/platform/github/test/node/githubQueryService.test.ts +++ b/src/vs/platform/github/test/node/githubQueryService.test.ts @@ -10,12 +10,14 @@ import { hasKey } from '../../../../base/common/types.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { GitHubRepositoryRef } from '../../common/githubQueryService.js'; import { GitHubHostCapabilities } from '../../common/githubTypes.js'; import { GitHubCredential, GitHubCredentialInvalidation, IGitHubCredentials } from '../../common/githubCredentialService.js'; import { IGitHubCapabilities } from '../../common/githubHostCapabilitiesService.js'; import { GitHubEntityPollingPolicy, GitHubQueryService } from '../../common/githubQueryServiceImpl.js'; import { GitHubRequestError, GitHubTransport } from '../../common/githubTransport.js'; +import { GitHubService } from '../../common/githubService.js'; import { FakeScheduler } from './fakeScheduler.js'; import { nodeFetch } from './nodeFetch.js'; import { @@ -183,6 +185,266 @@ suite('GitHubQueryService', () => { }; } + suite('repository query contracts', () => { + function setupClient(fetch: typeof globalThis.fetch, apiBaseUri = 'https://api.github.com') { + const engine = disposables.add(new GitHubService({ + credentialProvider: { onDidChange: Event.None, getToken: () => 'test-token' }, + fetch, + }, new NullLogService(), NullTelemetryService)); + const options = { apiBaseUri, graphQlUri: `${apiBaseUri}/graphql`, authorization: { providerId: 'github', sessionId: 'session', scopes: ['repo'] } }; + const reference = disposables.add(engine.acquireClient(options)); + const account = { host: new URL(apiBaseUri).host, accountId: '101' }; + return { engine, options, reference, client: reference.object, account, ref: { ...account, owner: 'octo', repo: 'repo' } }; + } + + for (const apiBaseUri of ['https://api.github.com', 'https://api.octo.ghe.com', 'https://github.example.test/api/v3']) { + test(`metadata and discovery use the authorized API base ${apiBaseUri}`, async () => { + const requests: string[] = []; + const repository = { + ...repositoryResponse('canonical/repository'), + clone_url: 'https://github.example.test/canonical/repository.git', + ssh_url: 'git@github.example.test:canonical/repository.git', + stargazers_count: 12, + description: null, + }; + const { client, account, ref } = setupClient(async input => { + const url = new URL(String(input)); + requests.push(url.href); + const data = url.pathname.endsWith('/user') ? { id: 101 } + : url.pathname.endsWith('/user/repos') ? [repository] + : url.pathname.endsWith('/search/repositories') ? { items: [repository], total_count: 1, incomplete_results: false } + : repository; + return new Response(JSON.stringify(data)); + }, apiBaseUri); + const metadata = await client.query.getRepository(ref, signal()); + const listed = await client.query.listRepositories(account, signal(), { + affiliation: ['owner', 'collaborator', 'organization_member'], + sort: 'updated', direction: 'desc', + }); + const query = 'vscode & tools in:name fork:true org:octo'; + const searched = await client.query.searchRepositories(account, query, signal(), { sort: 'stars', direction: 'asc' }); + assert.deepStrictEqual({ metadata, listed, searched, requests }, { + metadata: { + id: 'R1', owner: { id: '1', login: 'canonical' }, name: 'repository', nameWithOwner: 'canonical/repository', + stars: 12, defaultBranch: 'main', private: true, description: '', url: 'https://example.test/canonical/repository', + cloneUrl: repository.clone_url, sshUrl: repository.ssh_url, archived: false, fork: false, + }, + listed: { repositories: [metadata], nextPage: undefined }, + searched: { repositories: [metadata], nextPage: undefined, totalCount: 1, incompleteResults: false, limitReached: false }, + requests: [ + `${apiBaseUri}/user`, + `${apiBaseUri}/repos/octo/repo`, + `${apiBaseUri}/user/repos?page=1&per_page=100&affiliation=owner%2Ccollaborator%2Corganization_member&sort=updated&direction=desc`, + `${apiBaseUri}/search/repositories?q=vscode+%26+tools+in%3Aname+fork%3Atrue+org%3Aocto&page=1&per_page=100&sort=stars&order=asc`, + ], + }); + }); + } + + test('fetches one repository page at a time and preserves server pagination on a short page', async () => { + const requests: string[] = []; + const { client, account } = setupClient(async input => { + const url = new URL(String(input)); + requests.push(url.pathname + url.search); + if (url.pathname === '/user') { + return new Response('{"id":101}'); + } + return new Response(JSON.stringify([repositoryResponse(`octo/page-${url.searchParams.get('page')}`)]), { + headers: url.searchParams.get('page') === '2' ? { Link: '; rel="next"' } : {}, + }); + }); + const first = await client.query.listRepositories(account, signal(), { page: 2, perPage: 2 }); + assert.strictEqual(requests.length, 2); + const second = await client.query.listRepositories(account, signal(), { page: first.nextPage, perPage: 2 }); + assert.deepStrictEqual({ + names: [first, second].map(page => page.repositories.map(repository => repository.name)), + nextPages: [first.nextPage, second.nextPage], + requests, + }, { + names: [['page-2'], ['page-3']], nextPages: [3, undefined], + requests: ['/user', '/user/repos?page=2&per_page=2', '/user/repos?page=3&per_page=2'], + }); + }); + + for (const scenario of [ + { page: 1, totalCount: 0, incompleteResults: false, link: false, nextPage: undefined, limitReached: false }, + { page: 2, totalCount: 250, incompleteResults: true, link: true, nextPage: 3, limitReached: false }, + { page: 1, totalCount: 1001, incompleteResults: false, link: true, nextPage: 2, limitReached: true }, + { page: 10, totalCount: 1000, incompleteResults: false, link: false, nextPage: undefined, limitReached: false }, + { page: 10, totalCount: 1001, incompleteResults: true, link: true, nextPage: undefined, limitReached: true }, + ]) { + test(`reports search completeness and the result ceiling ${JSON.stringify(scenario)}`, async () => { + let requests = 0; + const { client, account } = setupClient(async input => { + requests++; + if (new URL(String(input)).pathname === '/user') { + return new Response('{"id":101}'); + } + return new Response(JSON.stringify({ items: [], total_count: scenario.totalCount, incomplete_results: scenario.incompleteResults }), { + headers: scenario.link ? { Link: `; rel="next"` } : {}, + }); + }); + const result = await client.query.searchRepositories(account, 'repo', signal(), { page: scenario.page }); + assert.deepStrictEqual({ result, requests }, { + result: { + repositories: [], nextPage: scenario.nextPage, totalCount: scenario.totalCount, + incompleteResults: scenario.incompleteResults, limitReached: scenario.limitReached, + }, + requests: 2, + }); + }); + } + + test('validates repository discovery inputs before resolving credentials or dispatching requests', async () => { + let requests = 0; + const { client, account } = setupClient(async () => { + requests++; + throw new Error('Unexpected request'); + }); + for (const options of [{ page: 0 }, { page: 1.5 }, { page: Number.MAX_SAFE_INTEGER + 1 }, { perPage: 0 }, { perPage: 101 }, { perPage: NaN }]) { + await assert.rejects(client.query.listRepositories(account, signal(), options), { kind: 'validation' }); + await assert.rejects(client.query.searchRepositories(account, 'repo', signal(), options), { kind: 'validation' }); + } + await assert.rejects(client.query.listRepositories(account, signal(), { affiliation: [] }), { kind: 'validation' }); + await assert.rejects(client.query.searchRepositories(account, ' \t ', signal()), { kind: 'validation' }); + await assert.rejects(client.query.searchRepositories(account, 'repo', signal(), { page: 11 }), { kind: 'validation' }); + await assert.rejects(client.query.searchRepositories(account, 'repo', signal(), { page: 1001, perPage: 1 }), { kind: 'validation' }); + assert.strictEqual(requests, 0); + }); + + for (const response of [ + {}, + { items: [], total_count: 1 }, + { items: [], total_count: -1, incomplete_results: false }, + { items: [], total_count: 1.5, incomplete_results: false }, + { items: [], total_count: 1, incomplete_results: 'false' }, + { items: {}, total_count: 1, incomplete_results: false }, + { items: [{}], total_count: 1, incomplete_results: false }, + ]) { + test(`rejects malformed search results without retrying ${JSON.stringify(response)}`, async () => { + let requests = 0; + const { client, account } = setupClient(async input => { + requests++; + return new Response(JSON.stringify(new URL(String(input)).pathname === '/user' ? { id: 101 } : response)); + }); + await assert.rejects(client.query.searchRepositories(account, 'repo', signal()), { kind: 'malformedResponse' }); + assert.strictEqual(requests, 2); + }); + } + + test('rejects oversized repository pages and preserves authorization error details', async () => { + const requests: string[] = []; + const { client, account, ref } = setupClient(async input => { + const path = new URL(String(input)).pathname; + requests.push(path); + return path === '/user' ? new Response('{"id":101}') + : path === '/user/repos' ? new Response(JSON.stringify([repositoryResponse('octo/one'), repositoryResponse('octo/two')])) + : new Response('{"message":"Forbidden"}', { status: 403 }); + }); + await assert.rejects(client.query.listRepositories(account, signal(), { perPage: 1 }), { kind: 'malformedResponse' }); + await assert.rejects(client.query.getRepository(ref, signal()), { kind: 'authorization', statusCode: 403 }); + assert.deepStrictEqual(requests, ['/user', '/user/repos', '/repos/octo/repo']); + }); + + test('rejects a mismatched account without invalidating the authorized repository client', async () => { + const requests: string[] = []; + const { client, account, ref } = setupClient(async input => { + const path = new URL(String(input)).pathname; + requests.push(path); + return new Response(JSON.stringify(path === '/user' ? { id: 101 } : repositoryResponse('octo/repo'))); + }); + await assert.rejects(client.query.listRepositories({ ...account, accountId: 'other' }, signal()), { kind: 'authentication' }); + await assert.rejects(client.query.searchRepositories({ ...account, host: 'other.example.test' }, 'repo', signal()), { kind: 'authentication' }); + await client.query.getRepository(ref, signal()); + assert.deepStrictEqual(requests, ['/user', '/repos/octo/repo']); + }); + + test('one-shot and subscribed repository reads share transport ownership and ETags without cancelling peers', async () => { + const started = new DeferredPromise(); + const release = new DeferredPromise(); + const etags: (string | null)[] = []; + const { engine, options, reference, client, ref } = setupClient(async (input, init) => { + if (new URL(String(input)).pathname === '/user') { + return new Response('{"id":101}'); + } + etags.push(new Headers(init?.headers).get('If-None-Match')); + if (etags.length === 1) { + assert.ok(init?.signal); + await started.complete(init.signal); + await release.p; + return new Response(JSON.stringify(repositoryResponse('octo/repo')), { headers: { ETag: '"repository"' } }); + } + return new Response(null, { status: 304 }); + }); + const peer = disposables.add(engine.acquireClient(options)); + const subscription = disposables.add(client.query.subscribeRepository(ref, { priority: 'visible' })); + const refreshed = subscription.refresh(); + const controller = new AbortController(); + const reason = new Error('The first consumer cancelled'); + const rejected = assert.rejects(client.query.getRepository(ref, controller.signal), error => error === reason); + const peerRead = peer.object.query.getRepository(ref, signal()); + const requestSignal = await started.p; + controller.abort(reason); + reference.dispose(); + await rejected; + const aborted = requestSignal.aborted; + await release.complete(); + const [repository] = await Promise.all([peerRead, refreshed]); + const revalidated = await peer.object.query.getRepository(ref, signal()); + assert.deepStrictEqual({ + aborted, etags, + subscribed: subscription.resource.state.get().value, + revalidated, + }, { aborted: false, etags: [null, '"repository"'], subscribed: repository, revalidated: repository }); + }); + + for (const isolation of ['session', 'scopes', 'issuer'] as const) { + test(`repository discovery never shares private ETags across ${isolation}`, async () => { + const etags: (string | null)[] = []; + const { engine, options, client, account } = setupClient(async (input, init) => { + if (new URL(String(input)).pathname === '/user') { + return new Response('{"id":101}'); + } + etags.push(new Headers(init?.headers).get('If-None-Match')); + return new Response(JSON.stringify([repositoryResponse('octo/private')]), { headers: { ETag: '"private"' } }); + }); + const peer = disposables.add(engine.acquireClient({ + ...options, + authorization: { + ...options.authorization, + ...(isolation === 'session' ? { sessionId: 'peer' } + : isolation === 'scopes' ? { scopes: ['read:user'] } + : { authorizationServer: 'https://github.example.test/login/oauth' }), + }, + })); + await client.query.listRepositories(account, signal()); + await Promise.all([client.query.listRepositories(account, signal()), peer.object.query.listRepositories(account, signal())]); + assert.deepStrictEqual(etags, [null, '"private"', null]); + }); + } + + test('releasing the final client reference cancels an outstanding repository search', async () => { + const started = new DeferredPromise(); + const response = new DeferredPromise(); + let requests = 0; + const { client, reference, account } = setupClient(async (input, init) => { + requests++; + if (new URL(String(input)).pathname === '/user') { + return new Response('{"id":101}'); + } + assert.ok(init?.signal); + await started.complete(init.signal); + return response.p; + }); + const rejected = assert.rejects(client.query.searchRepositories(account, 'repo', signal())); + const requestSignal = await started.p; + reference.dispose(); + await rejected; + await response.complete(new Response('{"items":[],"total_count":0,"incomplete_results":false}')); + assert.deepStrictEqual({ requests, aborted: requestSignal.aborted }, { requests: 2, aborted: true }); + }); + }); + test('hydrates repository and issue resources in one GraphQL request', async () => { await withServer(async server => { server.enqueue(gitHubGraphQLStep({ From 4cc4da802681c8859d7e81f63ab202f253112b60 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 3 Oct 2026 22:00:31 -0700 Subject: [PATCH 07/49] github: avoid throttling generic 403 denials (#339521) Address GitHub protocol-audit finding: a generic HTTP 403 permission refusal containing "Rate Limit Exceeded" must remain an authorization error, not invent a shared quota cooldown. Share one internal HTTP evidence classifier between transport errors, response telemetry and the GitHub cooldown coordinator. Retain primary exhaustion from valid remaining-zero headers, HTTP 429, valid Retry-After, or an explicit primary-limit message without usable remaining-quota feedback. Retain explicit secondary-limit headers/messages, reset timing, Retry-After precedence and the existing unhinted cooldown floor. Reject malformed numeric and Retry-After evidence on HTTP refusals, and preserve already-established cooldowns when a concurrent response reports healthy quota. Keep storage-origin download 403s as authorization failures. Reads/mutations, bootstrap credentials, cancellation/deadlines and account/host isolation keep their existing execution paths. --- src/vs/platform/github/README.md | 2 + .../common/githubRateLimitCoordinator.ts | 64 +++---- .../platform/github/common/githubTransport.ts | 20 +- .../test/common/githubBootstrapClient.test.ts | 23 +++ .../test/node/githubHttpRateLimits.test.ts | 173 ++++++++++++++++++ .../test/node/githubRequestTelemetry.test.ts | 27 +++ 6 files changed, 264 insertions(+), 45 deletions(-) create mode 100644 src/vs/platform/github/test/node/githubHttpRateLimits.test.ts diff --git a/src/vs/platform/github/README.md b/src/vs/platform/github/README.md index f676c717ede9..37d27ac6ac09 100644 --- a/src/vs/platform/github/README.md +++ b/src/vs/platform/github/README.md @@ -101,6 +101,8 @@ Internal requests carry caller attribution and a deadline. Current transport def - Credential resolution has a separate five-minute caller deadline covering token acquisition, identity backoff and shared identity lookup. A caller timing out does not erase server cooldowns or cancel another caller's identity lookup. - Long server cooldowns use bounded native timer chunks. Queue drains also expire overdue active requests after wall-clock jumps; rejected unique reads never retain coalescing entries or waiter timers. +HTTP error classification, response telemetry and cooldowns share the same quota-evidence check. A generic HTTP 403 with "Rate Limit Exceeded" wording remains an authorization failure unless valid exhausted-quota or `Retry-After` headers, an explicit primary message without usable remaining-quota feedback, or explicit secondary-limit evidence establish throttling. HTTP 429 still establishes throttling; malformed refusal headers do not invent cooldowns, healthy quota observations do not erase existing waits, and storage-origin download denials do not establish GitHub account limits. Later exhausted-quota responses extend existing waits while preserving `Retry-After` precedence. + ### Request identification Bindings supply trusted product/channel/version and originating component/version metadata. Configured GitHub endpoints receive `X-Client-Application`, `X-Client-Source`, an allowlisted `X-Client-Feature`, and `X-Is-Retry` on Node egress. Shared reads retain the initiating caller's attribution; arbitrary caller strings are never sent. diff --git a/src/vs/platform/github/common/githubRateLimitCoordinator.ts b/src/vs/platform/github/common/githubRateLimitCoordinator.ts index 1dd0b4c55313..9857e13b93ec 100644 --- a/src/vs/platform/github/common/githubRateLimitCoordinator.ts +++ b/src/vs/platform/github/common/githubRateLimitCoordinator.ts @@ -17,45 +17,35 @@ export class GitHubRateLimitCoordinator extends CooldownState { updateFromResponse(account: RequestAccount, response: Response, responseBody?: string, fallbackResource = 'core'): void { const resource = response.headers.get('x-ratelimit-resource') ?? fallbackResource; const isGraphQL = resource === 'graphql'; + const strictHeaders = isGraphQL || response.status === 403 || response.status === 429; const key = this._key(account, resource); const previous = this._states.get(key); - const previousBlockedUntil = previous?.blockedUntil ?? (isGraphQL && previous?.remaining === 0 ? previous.resetAt : undefined); + const previousBlockedUntil = previous?.blockedUntil ?? (previous?.remaining === 0 ? previous.resetAt : undefined); const now = this._scheduler.now(); - const retryAfter = parseRetryAfter(response.headers.get('retry-after'), now, isGraphQL); - const resetSeconds = parseHeaderNumber(response.headers.get('x-ratelimit-reset'), isGraphQL); - const remaining = parseHeaderNumber(response.headers.get('x-ratelimit-remaining'), isGraphQL); - const rateLimited = isRateLimited(response.status, responseBody); - const secondaryLimited = rateLimited && isSecondaryRateLimit(responseBody); - // GitHub's documented order: honour `retry-after`; otherwise wait for the - // reset only once the quota is actually spent. A secondary limit reports - // the primary window, so obeying its reset would park the account for up - // to an hour over a refusal that needs a minute. + const retryAfter = parseRetryAfter(response.headers.get('retry-after'), now, strictHeaders); + const resetSeconds = parseHeaderNumber(response.headers.get('x-ratelimit-reset'), strictHeaders); + const remaining = parseHeaderNumber(response.headers.get('x-ratelimit-remaining'), strictHeaders); + const rateLimit = classifyGitHubHttpRateLimit(response, responseBody); + const secondaryLimited = rateLimit === 'secondary'; + // A secondary limit can report an unspent primary quota window. const hinted = retryAfter !== undefined ? now + retryAfter * 1000 : remaining === 0 && resetSeconds !== undefined ? resetSeconds * 1000 : undefined; - // A refusal must always park the caller, including when the only hint - // GitHub gave has already elapsed and would otherwise retry at once. + // Expired hints must not let a rate-limited refusal retry immediately. const refusedUntil = hinted !== undefined && hinted > now ? hinted : now + unhintedRateLimitCooldown; - // Every rate-limited refusal parks its resource, notably the primary form - // GitHub reports as 403 with spent quota headers rather than as 429. Only - // the body separates that from an authorization failure, which must stay - // unparked so a credential problem still surfaces immediately. const blockedUntil = secondaryLimited ? undefined - : rateLimited || (isGraphQL && remaining === 0) + : rateLimit !== undefined || (isGraphQL && remaining === 0) ? refusedUntil - : retryAfter !== undefined ? now + retryAfter * 1000 : undefined; + : hinted; if (secondaryLimited) { const accountKey = RequestQueue.accountKey(account); - // GitHub asks clients that hit a secondary limit to wait at least a - // minute when it gives no usable hint, and the refusal parks the - // whole account rather than only the resource that observed it. this._accountBlockedUntil.set(accountKey, Math.max(refusedUntil, this._accountBlockedUntil.get(accountKey) ?? 0)); } this._states.set(key, { - limit: parseHeaderNumber(response.headers.get('x-ratelimit-limit'), isGraphQL) ?? previous?.limit, + limit: parseHeaderNumber(response.headers.get('x-ratelimit-limit'), strictHeaders) ?? previous?.limit, remaining: remaining ?? previous?.remaining, - used: parseHeaderNumber(response.headers.get('x-ratelimit-used'), isGraphQL) ?? previous?.used, + used: parseHeaderNumber(response.headers.get('x-ratelimit-used'), strictHeaders) ?? previous?.used, resetAt: resetSeconds !== undefined ? resetSeconds * 1000 : previous?.resetAt, blockedUntil: previousBlockedUntil !== undefined && previousBlockedUntil > now ? Math.max(previousBlockedUntil, blockedUntil ?? 0) : blockedUntil, @@ -98,18 +88,20 @@ export class GitHubRateLimitCoordinator extends CooldownState { } } -/** - * Whether GitHub refused the request for rate limiting. Primary exhaustion is - * reported as 403 with the quota headers rather than as 429, and only the body - * tells it apart from an authorization failure. - */ -function isRateLimited(status: number, body: string | undefined): boolean { - if (status === 429) { - return true; +/** A generic "Rate Limit Exceeded" message can also accompany non-quota 403 denials. */ +export function classifyGitHubHttpRateLimit(response: Pick, body?: string): 'primary' | 'secondary' | undefined { + if (response.status !== 403 && response.status !== 429) { + return undefined; } - return status === 403 && (body?.toLowerCase().includes('rate limit') ?? false); -} - -function isSecondaryRateLimit(body: string | undefined): boolean { - return body?.toLowerCase().includes('secondary rate limit') ?? false; + if (response.headers.get('x-github-secondary-rate-limited')?.toLowerCase() === 'true' + || /\bsecondary rate limit\b|\babuse detection mechanism\b/i.test(body ?? '')) { + return 'secondary'; + } + const remaining = parseHeaderNumber(response.headers.get('x-ratelimit-remaining'), true); + if (response.status === 429 || remaining === 0 + || parseRetryAfter(response.headers.get('retry-after'), 0, true) !== undefined + || remaining === undefined && /\bAPI rate limit exceeded\b/i.test(body ?? '')) { + return 'primary'; + } + return undefined; } diff --git a/src/vs/platform/github/common/githubTransport.ts b/src/vs/platform/github/common/githubTransport.ts index dab50ca6de1b..2034edf6945e 100644 --- a/src/vs/platform/github/common/githubTransport.ts +++ b/src/vs/platform/github/common/githubTransport.ts @@ -11,7 +11,7 @@ import { cancelResponseBody, parseResponseJson, readBoundedResponse } from './re import { IInFlightOperation, OperationWaiters } from './operationWaiters.js'; import { GitHubGraphQLError, GitHubRequestError, GitHubRequestRateLimitError, GitHubRequestTimeoutError } from './githubTypes.js'; import { AccountHandle, AnonymousAccount, BootstrapAccount, RequestFetch, RequestAccount, RequestContext, RequestErrorKind, RequestKind, RequestOptions, RequestPriority, RequestOutcome, requestOutcome, RequestError, RequestTimeoutError, RequestRateLimitError } from './types.js'; -import { GitHubRateLimitCoordinator } from './githubRateLimitCoordinator.js'; +import { classifyGitHubHttpRateLimit, GitHubRateLimitCoordinator } from './githubRateLimitCoordinator.js'; import { GitHubRequestMetadata } from './githubRequestMetadata.js'; import { RequestQueue, RequestQueueOptions } from './requestQueue.js'; import { IRequestScheduler, schedulerDelay, systemRequestScheduler } from './scheduler.js'; @@ -348,7 +348,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { } else if (response.body) { cancelResponseBody(response.body, this._logService); } - const kind = classifyHttpError(response.status, diagnosticBody); + const kind = !authenticated && response.status === 403 ? 'authorization' : classifyHttpError(response, diagnosticBody); if (response.status === 403 && kind === 'rateLimit') { this._telemetry?.record('rateLimitedResponses'); } @@ -760,7 +760,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { const result = await readBoundedResponse(response, this._options.maximumResponseBytes, signal, this._logService); signal.throwIfAborted(); const body = new TextDecoder().decode(result.bytes); - if (response.status === 403 && classifyHttpError(response.status, body) === 'rateLimit') { + if (response.status === 403 && classifyHttpError(response, body) === 'rateLimit') { this._telemetry?.record('rateLimitedResponses'); } this._rateLimits.updateFromResponse(account, response, body, resource); @@ -820,7 +820,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { private _httpError(prefix: string, response: Response, body: string): GitHubRequestError { const detail = formatErrorBody(body); const message = `${prefix} - ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`; - return new GitHubRequestError(message, classifyHttpError(response.status, body), response.status, body, undefined, response.statusText); + return new GitHubRequestError(message, classifyHttpError(response, body), response.status, body, undefined, response.statusText); } private _parseJson(body: string, message: string): T { @@ -903,14 +903,16 @@ function restResource(url: string): string { return /^(?:\/api\/v3)?\/search\//.test(new URL(url).pathname) ? 'search' : 'core'; } -function classifyHttpError(statusCode: number, body: string): RequestErrorKind { - switch (statusCode) { +function classifyHttpError(response: Response, body: string): RequestErrorKind { + if (classifyGitHubHttpRateLimit(response, body)) { + return 'rateLimit'; + } + switch (response.status) { case 401: return 'authentication'; - case 403: return body.toLowerCase().includes('rate limit') ? 'rateLimit' : 'authorization'; + case 403: return 'authorization'; case 404: return 'notFound'; case 422: return 'validation'; - case 429: return 'rateLimit'; - default: return statusCode >= 500 ? 'server' : 'unknown'; + default: return response.status >= 500 ? 'server' : 'unknown'; } } diff --git a/src/vs/platform/github/test/common/githubBootstrapClient.test.ts b/src/vs/platform/github/test/common/githubBootstrapClient.test.ts index 7ce15c8203d6..dd0e69ead815 100644 --- a/src/vs/platform/github/test/common/githubBootstrapClient.test.ts +++ b/src/vs/platform/github/test/common/githubBootstrapClient.test.ts @@ -58,6 +58,29 @@ suite('GitHub bootstrap clients', () => { }); }); + for (const accountId of [undefined, '101']) { + test(`a generic 403 does not throttle ${accountId ? 'known' : 'unknown'} bootstrap credentials`, () => runWithFakedTimers({}, async () => { + const requests: string[] = []; + const blockedUntil: number[] = []; + const service = create(async input => { + const path = new URL(String(input)).pathname; + requests.push(path); + return path === '/denied' + ? Response.json({ message: 'Rate Limit Exceeded' }, { status: 403, headers: { 'x-ratelimit-remaining': '4999' } }) + : Response.json({ ok: true }); + }); + const first = store.add(service.acquireBootstrapClient({ apiBaseUri, token: 'first', accountId })); + await assert.rejects(first.object.get('/denied', signal()), { kind: 'authorization', statusCode: 403 }); + await first.object.get('/after', signal(), { deadline: Date.now() + 100, onBlockedUntil: time => blockedUntil.push(time) }); + first.dispose(); + const second = store.add(service.acquireBootstrapClient({ apiBaseUri, token: 'second', accountId })).object; + await second.get('/replacement', signal(), { deadline: Date.now() + 100, onBlockedUntil: time => blockedUntil.push(time) }); + assert.deepStrictEqual({ requests, hasCooldown: blockedUntil.some(time => time > Date.now()) }, { + requests: ['/denied', '/after', '/replacement'], hasCooldown: false, + }); + })); + } + test('unknown credentials retain a shared origin cooldown across disposal and token changes', () => runWithFakedTimers({}, async () => { let calls = 0; const service = create(async () => { calls++; return new Response(null, { status: 429, headers: { 'Retry-After': '60' } }); }); diff --git a/src/vs/platform/github/test/node/githubHttpRateLimits.test.ts b/src/vs/platform/github/test/node/githubHttpRateLimits.test.ts new file mode 100644 index 000000000000..480df642854a --- /dev/null +++ b/src/vs/platform/github/test/node/githubHttpRateLimits.test.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { GitHubTransport } from '../../common/githubTransport.js'; +import { FakeScheduler } from './fakeScheduler.js'; + +suite('GitHub HTTP rate limits', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const account = { host: 'github.example.test', accountId: '1' }; + const url = 'https://github.example.test/repos/owner/repo'; + const signal = () => new AbortController().signal; + const healthyQuota = { 'x-ratelimit-remaining': '4999', 'x-ratelimit-reset': '4600' }; + const spentQuota = { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1120' }; + const cases: { + readonly name: string; + readonly headers?: Readonly>; + readonly status?: number; + readonly message?: string; + readonly limit?: 'primary' | 'secondary'; + readonly delay?: number; + }[] = [ + { name: 'generic denial without quota headers' }, + { name: 'generic denial with healthy quota', headers: healthyQuota }, + { name: 'generic denial with only a reset', headers: { 'x-ratelimit-reset': '4600' } }, + { name: 'generic denial with invalid quota', headers: { 'x-ratelimit-remaining': 'invalid', 'x-ratelimit-reset': 'invalid' } }, + { name: 'generic denial with a false secondary hint', headers: { ...healthyQuota, 'x-github-secondary-rate-limited': 'false' } }, + { name: 'healthy quota overrides primary-sounding text', message: 'API rate limit exceeded for user ID 1.', headers: healthyQuota }, + ...[' ', '0x0', '0.0', '0e0', '-0', '-1', '9007199254740992'].map(remaining => ({ + name: `generic denial with malformed remaining ${JSON.stringify(remaining)}`, + headers: { 'x-ratelimit-remaining': remaining, 'x-ratelimit-reset': '4600' }, + })), + ...[' ', '-1', '0.5', '0x10', '1e3', '9007199254740992', '1970-01-01T00:20:00Z'].map(retryAfter => ({ + name: `generic denial with malformed Retry-After ${JSON.stringify(retryAfter)}`, + headers: { ...healthyQuota, 'retry-after': retryAfter }, + })), + { name: 'primary 403 without rate-limit text', message: 'Forbidden', headers: spentQuota, limit: 'primary', delay: 120_000 }, + { name: 'primary 403 without a reset', headers: { 'x-ratelimit-remaining': '0' }, limit: 'primary', delay: 60_000 }, + { name: 'primary 403 with an expired reset', headers: { ...spentQuota, 'x-ratelimit-reset': '999' }, limit: 'primary', delay: 60_000 }, + { name: 'primary 403 with a malformed reset', headers: { ...spentQuota, 'x-ratelimit-reset': '0x10000' }, limit: 'primary', delay: 60_000 }, + { name: 'primary 403 with malformed Retry-After and a valid reset', headers: { ...spentQuota, 'retry-after': '-1' }, limit: 'primary', delay: 120_000 }, + { name: 'explicit primary message without quota headers', message: 'API rate limit exceeded for user ID 1.', limit: 'primary', delay: 60_000 }, + { name: '403 with Retry-After seconds', headers: { ...healthyQuota, 'retry-after': '5' }, limit: 'primary', delay: 5_000 }, + { name: '403 with Retry-After date', headers: { ...healthyQuota, 'retry-after': new Date(1_008_000).toUTCString() }, limit: 'primary', delay: 8_000 }, + { name: '403 with zero Retry-After', headers: { ...healthyQuota, 'retry-after': '0' }, limit: 'primary', delay: 60_000 }, + { name: 'Retry-After takes precedence over the primary reset', headers: { ...spentQuota, 'retry-after': '5' }, limit: 'primary', delay: 5_000 }, + { name: 'unhinted HTTP 429', status: 429, message: 'Too many requests', limit: 'primary', delay: 60_000 }, + { name: 'HTTP 429 with Retry-After', status: 429, headers: { ...healthyQuota, 'retry-after': '5' }, limit: 'primary', delay: 5_000 }, + { name: 'explicit secondary message with healthy primary quota', message: 'You have exceeded a secondary rate limit.', headers: healthyQuota, limit: 'secondary', delay: 60_000 }, + { name: 'explicit secondary header', headers: { ...healthyQuota, 'x-github-secondary-rate-limited': 'true' }, limit: 'secondary', delay: 60_000 }, + { name: 'legacy secondary message', message: 'You have triggered an abuse detection mechanism.', headers: healthyQuota, limit: 'secondary', delay: 60_000 }, + { name: 'secondary limit with Retry-After', message: 'You have exceeded a secondary rate limit.', headers: { ...healthyQuota, 'retry-after': '120' }, limit: 'secondary', delay: 120_000 }, + { name: 'secondary limit with spent primary quota', message: 'You have exceeded a secondary rate limit.', headers: spentQuota, limit: 'secondary', delay: 120_000 }, + { name: 'HTTP 429 with a secondary header', status: 429, headers: { 'x-github-secondary-rate-limited': 'true' }, limit: 'secondary', delay: 60_000 }, + ]; + + for (const kind of ['REST read', 'REST mutation', 'GraphQL read', 'GraphQL mutation', 'download'] as const) { + for (const entry of cases) { + test(`${kind}: ${entry.name}`, async () => { + const scheduler = store.add(new FakeScheduler({ now: 1_000_000 })); + let calls = 0; + const transport = store.add(new GitHubTransport(async () => { + calls++; + return Response.json({ message: entry.message ?? 'Rate Limit Exceeded' }, { status: entry.status ?? 403, headers: entry.headers }); + }, scheduler)); + const request = kind === 'download' + ? transport.download(account, 'token', { url, timeout: 1_000, maximumBytes: 1 }, signal()) + : kind === 'REST read' || kind === 'REST mutation' + ? transport.rest(account, 'token', { method: kind === 'REST read' ? 'GET' : 'POST', url }, signal()) + : transport.graphql(account, 'token', 'https://github.example.test/graphql', + kind === 'GraphQL read' ? 'query { viewer { login } }' : 'mutation Change { change { id } }', {}, signal()); + await assert.rejects(request, { kind: entry.limit ? 'rateLimit' : 'authorization', statusCode: entry.status ?? 403 }); + const resource = kind.startsWith('GraphQL') ? 'graphql' : 'core'; + assert.deepStrictEqual({ + calls, + delay: transport.rateLimits.getDelay(account, resource), + otherResource: transport.rateLimits.getDelay(account, resource === 'core' ? 'graphql' : 'core'), + otherAccount: transport.rateLimits.getDelay({ ...account, accountId: '2' }, resource), + otherHost: transport.rateLimits.getDelay({ ...account, host: 'other.example.test' }, resource), + timers: scheduler.pendingCount, + }, { + calls: 1, delay: entry.delay ?? 0, otherResource: entry.limit === 'secondary' ? entry.delay : 0, + otherAccount: 0, otherHost: 0, timers: 0, + }); + }); + } + } + + const exhaustedResponseCases: { + readonly name: string; + readonly headers: Readonly>; + readonly delay: number; + }[] = [ + { name: 'extend the cooldown to a later reset', headers: { 'x-ratelimit-reset': '1240' }, delay: 240_000 }, + { name: 'preserve the cooldown over an earlier reset', headers: { 'x-ratelimit-reset': '1060' }, delay: 120_000 }, + { name: 'prefer Retry-After over the later reset', headers: { 'x-ratelimit-reset': '1240', 'retry-after': '180' }, delay: 180_000 }, + { name: 'preserve the cooldown over a shorter Retry-After', headers: { 'x-ratelimit-reset': '1240', 'retry-after': '60' }, delay: 120_000 }, + ]; + for (const entry of exhaustedResponseCases) { + test(`successive exhausted REST responses ${entry.name}`, async () => { + const scheduler = store.add(new FakeScheduler({ now: 1_000_000 })); + const started = new DeferredPromise(); + const response = new DeferredPromise(); + const dispatchTimes: number[] = []; + const transport = store.add(new GitHubTransport(async () => { + dispatchTimes.push(scheduler.now()); + if (dispatchTimes.length === 1) { + await started.complete(); + return response.p; + } + return Response.json({}, { headers: healthyQuota }); + }, scheduler)); + const inFlight = transport.rest(account, 'token', { method: 'GET', url }, signal()); + await started.p; + transport.rateLimits.updateFromResponse(account, new Response(null, { headers: spentQuota })); + const initialDelay = transport.rateLimits.getDelay(account, 'core'); + await response.complete(Response.json({}, { headers: { ...spentQuota, ...entry.headers } })); + await inFlight; + const updatedDelay = transport.rateLimits.getDelay(account, 'core'); + const pending = transport.rest(account, 'token', { method: 'GET', url: `${url}/after` }, signal()); + scheduler.advanceBy(entry.delay - 1); + await Promise.resolve(); + const beforeExpiry = dispatchTimes.length; + scheduler.advanceBy(1); + await pending; + assert.deepStrictEqual({ + initialDelay, updatedDelay, beforeExpiry, dispatchTimes, + expired: transport.rateLimits.getDelay(account, 'core'), timers: scheduler.pendingCount, + }, { + initialDelay: 120_000, updatedDelay: entry.delay, beforeExpiry: 1, + dispatchTimes: [1_000_000, 1_000_000 + entry.delay], expired: 0, timers: 0, + }); + }); + } + + for (const source of ['successful exhausted quota', 'primary refusal', 'secondary refusal'] as const) { + test(`a generic 403 with healthy quota preserves a concurrent ${source} cooldown`, async () => { + const scheduler = store.add(new FakeScheduler({ now: 1_000_000 })); + const started = new DeferredPromise(); + const response = new DeferredPromise(); + const transport = store.add(new GitHubTransport(async () => { + await started.complete(); + return response.p; + }, scheduler)); + const rejected = assert.rejects(transport.rest(account, 'token', { method: 'GET', url }, signal()), { kind: 'authorization' }); + await started.p; + transport.rateLimits.updateFromResponse(account, new Response(null, { + status: source === 'successful exhausted quota' ? 200 : 403, + headers: source === 'secondary refusal' ? { 'retry-after': '120' } : spentQuota, + }), source === 'secondary refusal' ? 'You have exceeded a secondary rate limit.' : undefined); + const before = transport.rateLimits.getDelay(account, 'core'); + await response.complete(Response.json({ message: 'Rate Limit Exceeded' }, { status: 403, headers: healthyQuota })); + await rejected; + const after = { + core: transport.rateLimits.getDelay(account, 'core'), + graphql: transport.rateLimits.getDelay(account, 'graphql'), + }; + scheduler.advanceBy(119_999); + const beforeExpiry = transport.rateLimits.getDelay(account, 'core'); + scheduler.advanceBy(1); + assert.deepStrictEqual({ + before, after, beforeExpiry, expired: transport.rateLimits.getDelay(account, 'core'), timers: scheduler.pendingCount, + }, { + before: 120_000, after: { core: 120_000, graphql: source === 'secondary refusal' ? 120_000 : 0 }, + beforeExpiry: 1, expired: 0, timers: 0, + }); + }); + } +}); diff --git a/src/vs/platform/github/test/node/githubRequestTelemetry.test.ts b/src/vs/platform/github/test/node/githubRequestTelemetry.test.ts index a31097261151..0f7bc2d4011f 100644 --- a/src/vs/platform/github/test/node/githubRequestTelemetry.test.ts +++ b/src/vs/platform/github/test/node/githubRequestTelemetry.test.ts @@ -345,6 +345,33 @@ suite('GitHubRequestTelemetry', () => { }); } + for (const evidence of ['generic denial', 'primary quota', 'secondary header', 'HTTP 429'] as const) { + test(`HTTP rate-limit telemetry agrees with error classification for ${evidence}`, async () => { + const { scheduler, sink, telemetry } = setup(); + const limited = evidence !== 'generic denial'; + const transport = store.add(new GitHubTransport(async () => Response.json({ message: 'Rate Limit Exceeded' }, { + status: evidence === 'HTTP 429' ? 429 : 403, + headers: { + 'x-ratelimit-remaining': evidence === 'primary quota' ? '0' : '4999', + 'x-github-secondary-rate-limited': String(evidence === 'secondary header'), + }, + }), scheduler, false, undefined, undefined, telemetry)); + await assert.rejects(transport.rest(context().account, 'token', { + method: 'GET', url: 'https://github.example.test/repos/owner/repo', + }, context().signal), { kind: limited ? 'rateLimit' : 'authorization' }); + telemetry.flush(); + const summary = sink.summary(); + assert.deepStrictEqual({ + attempts: summary.wireAttempts, authorizationFailures: summary.authorizationFailures, + rateLimitFailures: summary.rateLimitFailures, limitedResponses: summary.rateLimitedResponses, + delay: transport.rateLimits.getDelay(context().account, 'core'), timers: scheduler.pendingCount, + }, { + attempts: 1, authorizationFailures: Number(!limited), rateLimitFailures: Number(limited), + limitedResponses: Number(limited), delay: limited ? 60_000 : 0, timers: 0, + }); + }); + } + test('counts the remaining failure and HTTP status categories', () => { const { sink, telemetry } = setup(); for (const kind of ['authentication', 'authorization', 'rateLimit', 'server', 'unknown'] as const) { From 445a177dce6c45e7dacaa8a9bb0d2f673467a1f9 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 3 Oct 2026 23:00:54 -0700 Subject: [PATCH 08/49] github: recognize REST quota route families (#339227) Replay the final resource-admission changes on main after the HTTP 403 prerequisite landed in #339521. Use fixed route families and legacy aliases, with a deadline-only REST fallback for unexpected buckets instead of learned mappings. Preserve request cooldowns across retries, bootstrap and identity cleanup without leaking unrelated REST waits into credential renewal or GraphQL. Retain all reviewed fixes from #339227. Validated with fresh transpilation, scoped type checking, hygiene, and 614 GitHub tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/github/README.md | 4 + .../github/common/githubCredentialService.ts | 6 +- .../common/githubRateLimitCoordinator.ts | 68 ++++- .../platform/github/common/githubService.ts | 2 +- .../platform/github/common/githubTransport.ts | 36 +-- .../test/node/githubRestRateLimits.test.ts | 251 ++++++++++++++++++ .../github/test/node/githubService.test.ts | 135 ++++++++++ 7 files changed, 472 insertions(+), 30 deletions(-) create mode 100644 src/vs/platform/github/test/node/githubRestRateLimits.test.ts diff --git a/src/vs/platform/github/README.md b/src/vs/platform/github/README.md index 37d27ac6ac09..a5e4197dd455 100644 --- a/src/vs/platform/github/README.md +++ b/src/vs/platform/github/README.md @@ -103,6 +103,10 @@ Internal requests carry caller attribution and a deadline. Current transport def HTTP error classification, response telemetry and cooldowns share the same quota-evidence check. A generic HTTP 403 with "Rate Limit Exceeded" wording remains an authorization failure unless valid exhausted-quota or `Retry-After` headers, an explicit primary message without usable remaining-quota feedback, or explicit secondary-limit evidence establish throttling. HTTP 429 still establishes throttling; malformed refusal headers do not invent cooldowns, healthy quota observations do not erase existing waits, and storage-origin download denials do not establish GitHub account limits. Later exhausted-quota responses extend existing waits while preserving `Retry-After` precedence. +REST admission recognizes fixed core, checks, code-search, semantic-search and other-search route families, including the Enterprise `/api/v3` prefix. Checks also honor legacy core waits; specialized searches honor legacy search waits, and code search honors its expanded bucket. This deliberately favors safe backoff over exact credential-specific quota isolation. + +An unexpected REST resource uses one account-scoped REST cooldown in the existing storage instead of a learned route mapping. This aggregate stores only deadlines derived from each response's own validated hints, not quota counters from unrelated resources. Valid server retry/reset timing governs admission, retries, authenticated downloads and bootstrap waiters; an exhausted successful response also establishes a wait. Such a wait can delay otherwise independent REST requests, but does not block another account or GraphQL. REST admission waits are separate from identity's core/secondary cooldown checks, so token renewal does not promote an unrelated REST wait into credential resolution; cooldowns returned by the identity request itself remain enforced. Successful identity responses preserve their fallback on the resolved account and renewal gate before bootstrap cleanup. HTTP quota counters and retry hints are validated strictly, and healthy feedback cannot erase a live wait. + ### Request identification Bindings supply trusted product/channel/version and originating component/version metadata. Configured GitHub endpoints receive `X-Client-Application`, `X-Client-Source`, an allowlisted `X-Client-Feature`, and `X-Is-Retry` on Node egress. Shared reads retain the initiating caller's attribution; arbitrary caller strings are never sent. diff --git a/src/vs/platform/github/common/githubCredentialService.ts b/src/vs/platform/github/common/githubCredentialService.ts index ceb3dc9c2863..5bde546d7767 100644 --- a/src/vs/platform/github/common/githubCredentialService.ts +++ b/src/vs/platform/github/common/githubCredentialService.ts @@ -219,6 +219,10 @@ export class GitHubCredentialService extends Disposable implements IGitHubCreden promise: this._resolveIdentity(token, generation, bootstrapAccount, apiBaseUri, controller.signal) .then(credential => { current.credential = credential; + const cooldown = this._transport.rateLimits.preserveRestFallback(bootstrapAccount, credential.account); + if (this._bootstrapQuotaAccount) { + this._transport.rateLimits.preserveCooldown(this._bootstrapQuotaAccount, 'core', cooldown); + } // Deliberately does not clear the failure record: a working // `/user` only proves identity resolution recovered, and when // GitHub is refusing this credential for real requests every @@ -240,7 +244,7 @@ export class GitHubCredentialService extends Disposable implements IGitHubCreden // An invalidated generation was not refused by GitHub, so // it must not count towards the delay the next one serves. if (!controller.signal.aborted) { - const cooldown = this._transport.rateLimits.getDelay(bootstrapAccount, 'core'); + const cooldown = this._transport.rateLimits.getRequestDelay(bootstrapAccount, 'core'); if (this._bootstrapQuotaAccount) { this._transport.rateLimits.preserveCooldown(this._bootstrapQuotaAccount, 'core', cooldown); } diff --git a/src/vs/platform/github/common/githubRateLimitCoordinator.ts b/src/vs/platform/github/common/githubRateLimitCoordinator.ts index 9857e13b93ec..e4a09bb91c91 100644 --- a/src/vs/platform/github/common/githubRateLimitCoordinator.ts +++ b/src/vs/platform/github/common/githubRateLimitCoordinator.ts @@ -10,21 +10,43 @@ import { RequestAccount } from './types.js'; /** GitHub's documented floor for retrying a rate limit it gave no reset hint for. */ const unhintedRateLimitCooldown = 60_000; +const unclassifiedRestResource = ''; /** Interprets GitHub quota headers and GraphQL feedback using shared cooldown storage. */ export class GitHubRateLimitCoordinator extends CooldownState { - updateFromResponse(account: RequestAccount, response: Response, responseBody?: string, fallbackResource = 'core'): void { - const resource = response.headers.get('x-ratelimit-resource') ?? fallbackResource; + /** Adds REST admission waits without changing the bucket-specific identity cooldown checks. */ + getRequestDelay(account: RequestAccount, resource: string): number { + let delay = super.getDelay(account, resource); + if (resource !== 'graphql') { + delay = Math.max(delay, super.getDelay(account, unclassifiedRestResource)); + for (const alias of restResourceAliases(resource)) { + delay = Math.max(delay, super.getDelay(account, alias)); + } + } + return delay; + } + + /** Preserves a transient identity response's REST-only wait on its resolved account. */ + preserveRestFallback(source: RequestAccount, target: RequestAccount): number { + const delay = Math.max(0, (this.getState(source, unclassifiedRestResource)?.blockedUntil ?? 0) - this._scheduler.now()); + this.preserveCooldown(target, unclassifiedRestResource, delay); + return delay; + } + + updateFromResponse(account: RequestAccount, response: Response, responseBody?: string, fallbackResource?: string): void { + const reportedResource = response.headers.get('x-ratelimit-resource') ?? fallbackResource ?? 'core'; + const resource = fallbackResource !== undefined && fallbackResource !== 'graphql' + && reportedResource !== fallbackResource && !restResourceAliases(fallbackResource).includes(reportedResource) + ? unclassifiedRestResource : reportedResource; const isGraphQL = resource === 'graphql'; - const strictHeaders = isGraphQL || response.status === 403 || response.status === 429; const key = this._key(account, resource); const previous = this._states.get(key); const previousBlockedUntil = previous?.blockedUntil ?? (previous?.remaining === 0 ? previous.resetAt : undefined); const now = this._scheduler.now(); - const retryAfter = parseRetryAfter(response.headers.get('retry-after'), now, strictHeaders); - const resetSeconds = parseHeaderNumber(response.headers.get('x-ratelimit-reset'), strictHeaders); - const remaining = parseHeaderNumber(response.headers.get('x-ratelimit-remaining'), strictHeaders); + const retryAfter = parseRetryAfter(response.headers.get('retry-after'), now, true); + const resetSeconds = parseHeaderNumber(response.headers.get('x-ratelimit-reset'), true); + const remaining = parseHeaderNumber(response.headers.get('x-ratelimit-remaining'), true); const rateLimit = classifyGitHubHttpRateLimit(response, responseBody); const secondaryLimited = rateLimit === 'secondary'; // A secondary limit can report an unspent primary quota window. @@ -43,10 +65,12 @@ export class GitHubRateLimitCoordinator extends CooldownState { this._accountBlockedUntil.set(accountKey, Math.max(refusedUntil, this._accountBlockedUntil.get(accountKey) ?? 0)); } this._states.set(key, { - limit: parseHeaderNumber(response.headers.get('x-ratelimit-limit'), strictHeaders) ?? previous?.limit, - remaining: remaining ?? previous?.remaining, - used: parseHeaderNumber(response.headers.get('x-ratelimit-used'), strictHeaders) ?? previous?.used, - resetAt: resetSeconds !== undefined ? resetSeconds * 1000 : previous?.resetAt, + ...(resource === unclassifiedRestResource ? {} : { + limit: parseHeaderNumber(response.headers.get('x-ratelimit-limit'), true) ?? previous?.limit, + remaining: remaining ?? previous?.remaining, + used: parseHeaderNumber(response.headers.get('x-ratelimit-used'), true) ?? previous?.used, + resetAt: resetSeconds !== undefined ? resetSeconds * 1000 : previous?.resetAt, + }), blockedUntil: previousBlockedUntil !== undefined && previousBlockedUntil > now ? Math.max(previousBlockedUntil, blockedUntil ?? 0) : blockedUntil, }); @@ -88,6 +112,30 @@ export class GitHubRateLimitCoordinator extends CooldownState { } } +export function getGitHubRestResource(url: string): string { + const target = new URL(url); + const path = target.pathname.replace(/^\/api\/v3(?=\/)/, ''); + if (/^\/(?:repos\/[^/]+\/[^/]+|repositories\/[^/]+)\/(?:commits\/.+\/)?check-(?:runs|suites)(?:\/|$)/.test(path)) { + return 'checks'; + } + if (/^\/search\/code\/?$/.test(path)) { + return 'code_search'; + } + if (/^\/search\/issues\/?$/.test(path) && ['semantic', 'hybrid'].includes(target.searchParams.get('search_type') ?? '')) { + return 'semantic_search'; + } + return path.startsWith('/search/') ? 'search' : 'core'; +} + +function restResourceAliases(resource: string): readonly string[] { + switch (resource) { + case 'checks': return ['core']; + case 'code_search': return ['search', 'code_search_expanded']; + case 'semantic_search': return ['search']; + default: return []; + } +} + /** A generic "Rate Limit Exceeded" message can also accompany non-quota 403 denials. */ export function classifyGitHubHttpRateLimit(response: Pick, body?: string): 'primary' | 'secondary' | undefined { if (response.status !== 403 && response.status !== 429) { diff --git a/src/vs/platform/github/common/githubService.ts b/src/vs/platform/github/common/githubService.ts index 682854127c41..7325b84ae5bf 100644 --- a/src/vs/platform/github/common/githubService.ts +++ b/src/vs/platform/github/common/githubService.ts @@ -92,7 +92,7 @@ export class GitHubService extends Disposable implements IGitHubService { super(); this._telemetry = this._register(new GitHubRequestTelemetry(_options.telemetrySource ?? 'other', systemRequestScheduler, telemetryService, _logService, _options.onDidChangeTelemetryLevel)); this._rateLimits = this._register(new GitHubRateLimitCoordinator(systemRequestScheduler)); - this._queue = this._register(new RequestQueue(systemRequestScheduler, context => this._rateLimits.getDelay(context.account, context.resource), undefined, this._telemetry)); + this._queue = this._register(new RequestQueue(systemRequestScheduler, context => this._rateLimits.getRequestDelay(context.account, context.resource), undefined, this._telemetry)); if (_options.credentialProvider) { this._register(_options.credentialProvider.onDidChange(change => this._invalidateClients(change))); } diff --git a/src/vs/platform/github/common/githubTransport.ts b/src/vs/platform/github/common/githubTransport.ts index 2034edf6945e..e5708d55c64d 100644 --- a/src/vs/platform/github/common/githubTransport.ts +++ b/src/vs/platform/github/common/githubTransport.ts @@ -11,7 +11,7 @@ import { cancelResponseBody, parseResponseJson, readBoundedResponse } from './re import { IInFlightOperation, OperationWaiters } from './operationWaiters.js'; import { GitHubGraphQLError, GitHubRequestError, GitHubRequestRateLimitError, GitHubRequestTimeoutError } from './githubTypes.js'; import { AccountHandle, AnonymousAccount, BootstrapAccount, RequestFetch, RequestAccount, RequestContext, RequestErrorKind, RequestKind, RequestOptions, RequestPriority, RequestOutcome, requestOutcome, RequestError, RequestTimeoutError, RequestRateLimitError } from './types.js'; -import { classifyGitHubHttpRateLimit, GitHubRateLimitCoordinator } from './githubRateLimitCoordinator.js'; +import { classifyGitHubHttpRateLimit, getGitHubRestResource, GitHubRateLimitCoordinator } from './githubRateLimitCoordinator.js'; import { GitHubRequestMetadata } from './githubRequestMetadata.js'; import { RequestQueue, RequestQueueOptions } from './requestQueue.js'; import { IRequestScheduler, schedulerDelay, systemRequestScheduler } from './scheduler.js'; @@ -141,7 +141,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { } this._fetch = fetchFn ?? ((input, init) => globalThis.fetch(input, init)); this._rateLimits = options.coordination?.rateLimits ?? this._register(new GitHubRateLimitCoordinator(_scheduler)); - this._queue = options.coordination?.queue ?? this._register(new RequestQueue(_scheduler, context => this._rateLimits.getDelay(context.account, context.resource), options.queue, _telemetry)); + this._queue = options.coordination?.queue ?? this._register(new RequestQueue(_scheduler, context => this._rateLimits.getRequestDelay(context.account, context.resource), options.queue, _telemetry)); } get rateLimits(): GitHubRateLimitCoordinator { @@ -192,7 +192,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { this._inFlight.set(coalescingKey, shared); const created = shared; const updateCooldown = () => created.waiters.setBlockedUntil(this._queue.isPending(controller.signal) - ? this._scheduler.now() + this._rateLimits.getDelay(account, restResource(request.url)) : 0); + ? this._scheduler.now() + this._rateLimits.getRequestDelay(account, getGitHubRestResource(this._redirects.get(request.url) ?? request.url)) : 0); const cooldownListener = account.kind === 'bootstrap' ? this._rateLimits.onDidChange(updateCooldown) : undefined; if (cooldownListener) { updateCooldown(); @@ -274,13 +274,14 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { } const deadline = this._deadline(request, Math.max(0, request.timeout)); const priority = request.priority ?? 'interactive'; - return this._logRequest('download', formatDownloadUrl(request.url), account, priority, signal, () => this._enqueueWithRateLimit(account, 'core', priority, signal, async (combinedSignal, onDispatch) => { + return this._logRequest('download', formatDownloadUrl(request.url), account, priority, signal, () => this._enqueueWithRateLimit(account, getGitHubRestResource(request.url), priority, signal, async (combinedSignal, onDispatch) => { const initialOrigin = new URL(request.url).origin; let url = request.url; let authenticated = true; for (let redirectCount = 0; redirectCount <= maximumRedirects; redirectCount++) { combinedSignal.throwIfAborted(); - if (authenticated && this._rateLimits.getDelay(account, 'core') > 0) { + const resource = getGitHubRestResource(url); + if (authenticated && this._rateLimits.getRequestDelay(account, resource) > 0) { throw new GitHubRequestError('GitHub download is rate limited', 'rateLimit'); } const headers: Record = { @@ -317,7 +318,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { } this._logService?.trace(`[GitHubTransport] Download request returned HTTP ${response.status}`); if (authenticated) { - this._rateLimits.updateFromResponse(account, response); + this._rateLimits.updateFromResponse(account, response, undefined, resource); } if ([301, 302, 307, 308].includes(response.status)) { if (response.body) { @@ -344,7 +345,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { if (authenticated && response.status === 403) { const prefix = await this._readDownloadBody(response, Math.min(maximumDownloadErrorBytes, this._options.maximumResponseBytes), combinedSignal); diagnosticBody = new TextDecoder().decode(prefix.bytes); - this._rateLimits.updateFromResponse(account, response, diagnosticBody); + this._rateLimits.updateFromResponse(account, response, diagnosticBody, resource); } else if (response.body) { cancelResponseBody(response.body, this._logService); } @@ -558,7 +559,8 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { ): Promise> { const priority = request.priority ?? (request.method === 'GET' ? 'interactive' : 'mutation'); const operation = `${request.method} ${formatRequestUrl(request.url)}`; - return this._logRequest('REST', operation, account, priority, signal, () => this._enqueueWithRateLimit(account, restResource(request.url), priority, signal, async (signal, onDispatch) => { + const resource = getGitHubRestResource(this._redirects.get(request.url) ?? request.url); + return this._logRequest('REST', operation, account, priority, signal, () => this._enqueueWithRateLimit(account, resource, priority, signal, async (signal, onDispatch) => { const cached = request.etag !== false && !request.unconditional ? this._restCache.get(cacheKey) : undefined; if (cached) { this._logService?.trace(`[GitHubTransport] Using cached ETag for ${operation}`); @@ -587,9 +589,9 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { }, request.method === 'GET', request.caller, onDispatch, anonymousApiBasePath); this._logService?.trace(`[GitHubTransport] REST ${operation} returned HTTP ${response.status}`); const finalUrl = response.url || this._redirects.get(request.url) || request.url; - const body = await this._readResponse(account, response, signal, restResource(request.url)); + const body = await this._readResponse(account, response, signal, getGitHubRestResource(finalUrl)); signal.throwIfAborted(); - this._logRateLimit(account, response.headers.get('x-ratelimit-resource') ?? 'core'); + this._logRateLimit(account, response.headers.get('x-ratelimit-resource') ?? resource); if (response.status === 304) { if (!cached) { throw new GitHubRequestError('GitHub returned 304 without a cached representation', 'malformedResponse', 304); @@ -686,7 +688,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { let url = this._redirects.get(initialUrl) ?? initialUrl; const initialOrigin = new URL(url).origin; for (let redirectCount = 0; redirectCount <= maximumRedirects; redirectCount++) { - const response = await this._fetchWithRetry(account, restResource(url), url, init, retry, caller, onDispatch); + const response = await this._fetchWithRetry(account, getGitHubRestResource(url), url, init, retry, caller, onDispatch); if (![301, 302, 307, 308].includes(response.status)) { if (url !== initialUrl) { this._redirects.set(initialUrl, url); @@ -717,8 +719,10 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { let failure: unknown; for (let attempt = 0; attempt < (retry ? 2 : 1); attempt++) { init.signal.throwIfAborted(); - if (this._rateLimits.getDelay(account, resource) > 0) { - throw new GitHubRequestError('GitHub request is rate limited', 'rateLimit'); + const cooldown = this._rateLimits.getRequestDelay(account, resource); + if (cooldown > 0) { + throw account.kind === 'bootstrap' ? new GitHubRequestRateLimitError(cooldown) + : new GitHubRequestError('GitHub request is rate limited', 'rateLimit'); } const headers = { ...init.headers, ...this._options.requestMetadata?.getHeaders(url, caller, attempt > 0) }; try { @@ -733,7 +737,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { throw init.signal.reason; } this._rateLimits.updateFromResponse(account, response, undefined, resource); - if (retry && attempt === 0 && response.status >= 500 && this._rateLimits.getDelay(account, resource) === 0) { + if (retry && attempt === 0 && response.status >= 500 && this._rateLimits.getRequestDelay(account, resource) === 0) { if (response.body) { cancelResponseBody(response.body, this._logService); } @@ -899,10 +903,6 @@ function isGraphQLRead(query: string): boolean { return /^(?:query\b|\{)/.test(query.slice(offset)); } -function restResource(url: string): string { - return /^(?:\/api\/v3)?\/search\//.test(new URL(url).pathname) ? 'search' : 'core'; -} - function classifyHttpError(response: Response, body: string): RequestErrorKind { if (classifyGitHubHttpRateLimit(response, body)) { return 'rateLimit'; diff --git a/src/vs/platform/github/test/node/githubRestRateLimits.test.ts b/src/vs/platform/github/test/node/githubRestRateLimits.test.ts new file mode 100644 index 000000000000..ddcad25b3f7e --- /dev/null +++ b/src/vs/platform/github/test/node/githubRestRateLimits.test.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 assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +import { GitHubService } from '../../common/githubService.js'; +import { GitHubTransport } from '../../common/githubTransport.js'; +import { FakeScheduler } from './fakeScheduler.js'; + +suite('GitHub REST rate limits', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const origin = 'https://github.example.test'; + const account = { host: 'github.example.test', accountId: '101' }; + const signal = () => new AbortController().signal; + const headers = (resource: string) => ({ + 'x-ratelimit-resource': resource, 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1060', + }); + const families = [ + { name: 'core', resource: 'core', first: '/repos/o/r/issues/1', next: '/repos/other/repo/pulls', independent: '/search/issues?q=test' }, + { name: 'checks', resource: 'checks', first: '/repos/o/r/commits/main/check-runs', next: '/repos/other/repo/check-runs/2/annotations', independent: '/repos/o/r/issues/1' }, + { name: 'enterprise checks', resource: 'checks', first: '/api/v3/repositories/1/check-suites/2/check-runs', next: '/api/v3/repos/o/r/commits/feature%2Fbranch/check-suites', independent: '/api/v3/repos/o/r/issues/1' }, + { name: 'search', resource: 'search', first: '/search/issues?q=first', next: '/search/repositories?q=second', independent: '/repos/o/r/issues/1' }, + { name: 'code search', resource: 'code_search', first: '/search/code?q=first', next: '/search/code?q=second', independent: '/search/issues?q=test' }, + { name: 'expanded code search', resource: 'code_search_expanded', first: '/api/v3/search/code?q=first', next: '/api/v3/search/code/?q=second', independent: '/api/v3/search/issues?q=test' }, + { name: 'semantic search', resource: 'semantic_search', first: '/search/issues?q=first&search_type=semantic', next: '/search/issues?q=second&search_type=hybrid', independent: '/search/issues?q=keyword' }, + { name: 'legacy checks', resource: 'core', first: '/repos/o/r/check-suites/1', next: '/repos/other/repo/check-runs/2', independent: '/search/issues?q=test' }, + { name: 'legacy code search', resource: 'search', first: '/search/code?q=first', next: '/search/code?q=second', independent: '/repos/o/r/issues/1' }, + ]; + + for (const family of families) { + for (const status of [200, 403, 503]) { + test(`${family.name}: HTTP ${status} quota feedback gates the next route`, async () => { + const scheduler = store.add(new FakeScheduler({ now: 1_000_000 })); + const calls: { path: string; at: number }[] = []; + const transport = store.add(new GitHubTransport(async input => { + const target = new URL(String(input)); + calls.push({ path: target.pathname + target.search, at: scheduler.now() }); + return calls.length === 1 ? Response.json({}, { status, headers: headers(family.resource) }) : Response.json({}); + }, scheduler)); + const first = transport.rest(account, 'token', { method: 'GET', url: origin + family.first }, signal()); + if (status === 200) { + await first; + } else { + await assert.rejects(first, { kind: status === 403 ? 'rateLimit' : 'server' }); + } + await transport.rest(account, 'token', { method: 'GET', url: origin + family.independent }, signal()); + const pending = transport.rest(account, 'token', { method: 'GET', url: origin + family.next }, signal()); + scheduler.advanceBy(59_999); + await Promise.resolve(); + const beforeReset = calls.length; + scheduler.advanceBy(1); + await pending; + assert.deepStrictEqual({ calls, beforeReset, timers: scheduler.pendingCount }, { + calls: [{ path: family.first, at: 1_000_000 }, { path: family.independent, at: 1_000_000 }, { path: family.next, at: 1_060_000 }], + beforeReset: 2, timers: 0, + }); + }); + } + } + + for (const resource of ['custom_resource', 'checks', 'graphql']) { + for (const status of [200, 403, 503]) { + test(`unexpected REST resource ${resource} on HTTP ${status} conservatively gates only that account's REST requests`, async () => { + const scheduler = store.add(new FakeScheduler({ now: 1_000_000 })); + const calls: number[] = []; + const transport = store.add(new GitHubTransport(async () => { + calls.push(scheduler.now()); + return calls.length === 1 ? Response.json({}, { status, headers: headers(resource) }) : Response.json({ data: {} }); + }, scheduler)); + const first = transport.rest(account, 'token', { method: 'GET', url: `${origin}/custom` }, signal()); + if (status === 200) { + await first; + } else { + await assert.rejects(first, { kind: status === 403 ? 'rateLimit' : 'server' }); + } + const reportedState = transport.rateLimits.getState(account, resource); + transport.rateLimits.updateFromResponse(account, new Response(null, { + headers: { ...headers('different_resource'), 'x-ratelimit-remaining': '4999' }, + }), undefined, 'core'); + await transport.graphql(account, 'token', `${origin}/graphql`, 'query { viewer { login } }', {}, signal()); + await transport.rest({ ...account, accountId: '202' }, 'peer', { method: 'GET', url: `${origin}/custom` }, signal()); + await transport.rest({ ...account, host: 'other.example.test' }, 'other', { method: 'GET', url: 'https://other.example.test/custom' }, signal()); + const pending = ['/repos/o/r/issues/1', '/repos/o/r/check-runs/1', '/search/code', '/search/issues?search_type=semantic'].map(path => + transport.rest(account, 'token', { method: 'GET', url: origin + path }, signal())); + scheduler.advanceBy(59_999); + await Promise.resolve(); + const beforeReset = calls.length; + scheduler.advanceBy(1); + await Promise.all(pending); + assert.deepStrictEqual({ + calls, beforeReset, timers: scheduler.pendingCount, reportedState, + }, { + calls: [1_000_000, 1_000_000, 1_000_000, 1_000_000, 1_060_000, 1_060_000, 1_060_000, 1_060_000], + beforeReset: 4, timers: 0, reportedState: undefined, + }); + }); + } + } + + for (const remaining of [undefined, '0.0', '4999', '0']) { + test(`expired fallback counters do not combine with another bucket: ${JSON.stringify(remaining)}`, () => runWithFakedTimers({ startTime: 1_000_000 }, async () => { + const calls: { path: string; at: number }[] = []; + const transport = store.add(new GitHubTransport(async input => { + const path = new URL(String(input)).pathname; + calls.push({ path, at: Date.now() }); + if (path === '/first') { + return Response.json({}, { headers: headers('custom_a') }); + } + if (path === '/second') { + return Response.json({}, { + headers: { + 'x-ratelimit-resource': 'custom_b', 'x-ratelimit-reset': '4600', + ...(remaining === undefined ? {} : { 'x-ratelimit-remaining': remaining }), + } + }); + } + return Response.json({}); + })); + try { + await transport.rest(account, 'token', { method: 'GET', url: `${origin}/first` }, signal()); + await timeout(60_000); + await transport.rest(account, 'token', { method: 'GET', url: `${origin}/second` }, signal()); + const next = transport.rest(account, 'token', { + method: 'GET', url: `${origin}/after`, deadline: Date.now() + 100, + }, signal()); + if (remaining === '0') { + await assert.rejects(next, { kind: 'timeout' }); + } else { + await next; + } + assert.deepStrictEqual(calls, [ + { path: '/first', at: 1_000_000 }, { path: '/second', at: 1_060_000 }, + ...(remaining === '0' ? [] : [{ path: '/after', at: 1_060_000 }]), + ]); + } finally { + transport.dispose(); + } + })); + } + + test('an unexpected bucket cannot bypass a queued deadline or cancellation', async () => { + const scheduler = store.add(new FakeScheduler({ now: 1_000_000 })); + let calls = 0; + const transport = store.add(new GitHubTransport(async () => { + calls++; + return Response.json({}, { status: 403, headers: headers('custom_resource') }); + }, scheduler)); + const request = { method: 'GET' as const, url: `${origin}/custom` }; + await assert.rejects(transport.rest(account, 'token', request, signal()), { kind: 'rateLimit' }); + const controller = new AbortController(); + const cancelled = assert.rejects(transport.rest(account, 'token', { ...request, url: `${origin}/search/code` }, controller.signal), /cancelled/); + const expired = assert.rejects(transport.rest(account, 'token', { ...request, deadline: scheduler.now() + 100 }, signal()), { kind: 'timeout' }); + controller.abort(new Error('cancelled')); + scheduler.advanceBy(100); + await Promise.all([cancelled, expired]); + assert.deepStrictEqual({ calls, timers: scheduler.pendingCount }, { calls: 1, timers: 0 }); + }); + + for (const remaining of [' ', '0x0', '0.0', '-1']) { + test(`malformed successful quota feedback cannot invent a shared REST wait: ${JSON.stringify(remaining)}`, async () => { + const scheduler = store.add(new FakeScheduler({ now: 1_000_000 })); + let calls = 0; + const transport = store.add(new GitHubTransport(async () => { + calls++; + return Response.json({}, { headers: { ...headers('custom_resource'), 'x-ratelimit-remaining': remaining, 'retry-after': '-1' } }); + }, scheduler)); + await transport.rest(account, 'token', { method: 'GET', url: `${origin}/custom` }, signal()); + await transport.rest(account, 'token', { method: 'GET', url: `${origin}/search/code` }, signal()); + assert.deepStrictEqual({ calls, delay: transport.rateLimits.getRequestDelay(account, 'core'), timers: scheduler.pendingCount }, { calls: 2, delay: 0, timers: 0 }); + }); + } + + for (const resource of ['checks', 'custom_resource']) { + for (const accountId of [undefined, account.accountId]) { + test(`queued bootstrap ${resource} cooldown survives ${accountId ? 'known-account' : 'unknown-account'} credential replacement`, () => runWithFakedTimers({}, async () => { + const started = new DeferredPromise(); + const response = new DeferredPromise(); + const blockedUntil: number[] = []; + let calls = 0; + const service = store.add(new GitHubService({ + fetch: async () => { + calls++; + await started.complete(); + return response.p; + }, + }, new NullLogService(), NullTelemetryService)); + const options = { apiBaseUri: origin, accountId }; + const first = store.add(service.acquireBootstrapClient({ ...options, token: 'first' })); + const peer = store.add(service.acquireBootstrapClient({ ...options, token: 'peer' })); + const firstRejected = assert.rejects(first.object.get('/repos/o/r/check-runs/1', signal()), { kind: 'rateLimit' }); + await started.p; + const peerRejected = assert.rejects(peer.object.get('/repos/other/repo/check-suites/2', signal(), { + deadline: Date.now() + 100, onBlockedUntil: time => blockedUntil.push(time), + }), { kind: 'rateLimit', statusCode: 429 }); + await response.complete(Response.json({}, { + status: 403, headers: { 'x-ratelimit-resource': resource, 'x-ratelimit-remaining': '0', 'retry-after': '60' }, + })); + await Promise.all([firstRejected, peerRejected]); + first.dispose(); + peer.dispose(); + const replacement = store.add(service.acquireBootstrapClient({ apiBaseUri: origin, accountId: account.accountId, token: 'replacement' })).object; + await assert.rejects(replacement.get('/repos/o/r/check-runs/3', signal(), { deadline: Date.now() + 100 }), { kind: 'rateLimit', statusCode: 429 }); + assert.deepStrictEqual({ calls, notified: blockedUntil.some(time => time > Date.now()) }, { calls: 1, notified: true }); + })); + } + } + + for (const kind of ['REST', 'download'] as const) { + test(`${kind} redirects cannot enter an exhausted checks bucket`, async () => { + const scheduler = store.add(new FakeScheduler({ now: 1_000_000 })); + const paths: string[] = []; + const transport = store.add(new GitHubTransport(async input => { + const path = new URL(String(input)).pathname; + paths.push(path); + return path === '/alias' + ? new Response(null, { status: 302, headers: { Location: '/repos/o/r/check-runs/2' } }) + : Response.json({}, { status: 403, headers: headers('checks') }); + }, scheduler)); + await assert.rejects(transport.rest(account, 'token', { method: 'GET', url: `${origin}/repos/o/r/check-runs/1` }, signal()), { kind: 'rateLimit' }); + const request = kind === 'REST' + ? transport.rest(account, 'token', { method: 'GET', url: `${origin}/alias` }, signal()) + : transport.download(account, 'token', { url: `${origin}/alias`, maximumBytes: 100, timeout: 1_000 }, signal()); + await assert.rejects(request, { kind: 'rateLimit' }); + assert.deepStrictEqual({ paths, timers: scheduler.pendingCount }, { paths: ['/repos/o/r/check-runs/1', '/alias'], timers: 0 }); + }); + } + + test('a cached redirect uses its target family before admission', async () => { + const scheduler = store.add(new FakeScheduler({ now: 1_000_000 })); + const paths: string[] = []; + const transport = store.add(new GitHubTransport(async input => { + const path = new URL(String(input)).pathname; + paths.push(path); + return path === '/alias' + ? new Response(null, { status: 302, headers: { Location: '/repos/o/r/check-runs/1' } }) + : path.endsWith('/2') ? Response.json({}, { status: 403, headers: headers('checks') }) : Response.json({}); + }, scheduler)); + await transport.rest(account, 'token', { method: 'GET', url: `${origin}/alias` }, signal()); + await assert.rejects(transport.rest(account, 'token', { method: 'GET', url: `${origin}/repos/o/r/check-runs/2` }, signal()), { kind: 'rateLimit' }); + const rejected = assert.rejects(transport.rest(account, 'token', { method: 'GET', url: `${origin}/alias`, deadline: scheduler.now() + 100 }, signal()), { kind: 'timeout' }); + scheduler.advanceBy(100); + await rejected; + assert.deepStrictEqual({ paths, timers: scheduler.pendingCount }, { paths: ['/alias', '/repos/o/r/check-runs/1', '/repos/o/r/check-runs/2'], timers: 0 }); + }); +}); diff --git a/src/vs/platform/github/test/node/githubService.test.ts b/src/vs/platform/github/test/node/githubService.test.ts index 5d3839284e6c..16fd1754f5cb 100644 --- a/src/vs/platform/github/test/node/githubService.test.ts +++ b/src/vs/platform/github/test/node/githubService.test.ts @@ -271,8 +271,143 @@ suite('GitHubService', () => { } })); } + + test(`unexpected REST fallback allows GraphQL after credential ${change}`, () => runWithFakedTimers({}, async () => { + const changes = disposables.add(new Emitter()); + let token = 'first-token'; + const requests: { path: string; at: number }[] = []; + const service = setup({ + credentialProvider: { onDidChange: changes.event, getToken: () => token }, + fetch: async input => { + const path = new URL(String(input)).pathname; + requests.push({ path, at: Date.now() }); + if (path === '/user') { + return Response.json({ id: 101 }); + } + if (path === '/repos/owner/repo') { + return Response.json({}, { + headers: { + 'x-ratelimit-resource': 'custom_resource', 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '3600', + } + }); + } + assert.strictEqual(path, '/graphql'); + return Response.json({ + data: { + pullRequest: { fields: [] }, + repository: { fields: [], pullRequests: { nodes: [], pageInfo: { endCursor: null, hasNextPage: false } } }, + } + }); + }, + }); + try { + const first = disposables.add(service.acquireClient(clientOptions())); + const credential = await first.object.credentials.getCredential(signal()); + await first.object.transport.rest(credential.account, credential.token, { + method: 'GET', url: 'https://api.github.com/repos/owner/repo', + }, signal()); + token = 'replacement-token'; + if (change === 'release') { + first.dispose(); + } else if (change === 'revoke') { + changes.fire({ providerId: 'github', sessionIds: ['session'] }); + } + const replacement = disposables.add(service.acquireClient(clientOptions())).object; + const page = await replacement.query.listPullRequests({ ...credential.account, owner: 'owner', repo: 'repo' }, undefined, signal()); + const renewed = await replacement.credentials.getCredential(signal()); + await assert.rejects(replacement.transport.rest(renewed.account, renewed.token, { + method: 'GET', url: 'https://api.github.com/repos/owner/repo', deadline: Date.now() + 100, + }, signal()), { kind: 'timeout' }); + assert.deepStrictEqual({ requests, page }, { + requests: [ + { path: '/user', at: 0 }, { path: '/repos/owner/repo', at: 0 }, + { path: '/user', at: 0 }, { path: '/graphql', at: 0 }, { path: '/graphql', at: 0 }, + ], + page: { pullRequests: [], cursor: undefined, hasNextPage: false }, + }); + } finally { + service.dispose(); + } + })); } + test('an unexpected identity-response bucket still gates credential renewal', () => runWithFakedTimers({}, async () => { + let token = 'first-token'; + const requests: number[] = []; + const service = setup({ + credentialProvider: { onDidChange: Event.None, getToken: () => token }, + fetch: async () => { + requests.push(Date.now()); + return requests.length === 1 + ? Response.json({}, { status: 429, headers: { 'x-ratelimit-resource': 'custom_identity', 'Retry-After': '5' } }) + : Response.json({ id: 101 }); + }, + }); + try { + const client = disposables.add(service.acquireClient(clientOptions())).object; + await assert.rejects(client.credentials.getCredential(signal()), { kind: 'rateLimit' }); + token = 'replacement-token'; + await client.credentials.getCredential(signal()); + assert.deepStrictEqual(requests, [0, 5_000]); + } finally { + service.dispose(); + } + })); + + test('a successful identity-response fallback survives bootstrap cleanup', () => runWithFakedTimers({}, async () => { + let token = 'first-token'; + const requests: { path: string; at: number }[] = []; + const service = setup({ + credentialProvider: { onDidChange: Event.None, getToken: () => token }, + fetch: async input => { + const path = new URL(String(input)).pathname; + requests.push({ path, at: Date.now() }); + if (path === '/user') { + return Response.json({ id: 101 }, requests.length === 1 ? { + headers: { 'x-ratelimit-resource': 'custom_identity', 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '5' }, + } : {}); + } + if (path === '/graphql') { + return Response.json({ + data: { + pullRequest: { fields: [] }, + repository: { fields: [], pullRequests: { nodes: [], pageInfo: { endCursor: null, hasNextPage: false } } }, + } + }); + } + assert.strictEqual(path, '/repos/owner/repo'); + return Response.json({}); + }, + }); + try { + const client = disposables.add(service.acquireClient(clientOptions())).object; + const credential = await client.credentials.getCredential(signal()); + const delays = { + rest: client.transport.rateLimits.getRequestDelay(credential.account, 'core'), + core: client.transport.rateLimits.getDelay(credential.account, 'core'), + graphql: client.transport.rateLimits.getRequestDelay(credential.account, 'graphql'), + }; + const page = await client.query.listPullRequests({ ...credential.account, owner: 'owner', repo: 'repo' }, undefined, signal()); + const request = { method: 'GET' as const, url: 'https://api.github.com/repos/owner/repo' }; + await assert.rejects(client.transport.rest(credential.account, credential.token, { + ...request, deadline: Date.now() + 100, + }, signal()), { kind: 'timeout' }); + token = 'replacement-token'; + const renewed = await client.credentials.getCredential(signal()); + await client.transport.rest(renewed.account, renewed.token, request, signal()); + assert.deepStrictEqual({ requests, delays, page }, { + requests: [ + { path: '/user', at: 0 }, { path: '/graphql', at: 0 }, { path: '/graphql', at: 0 }, + { path: '/user', at: 5_000 }, { path: '/repos/owner/repo', at: 5_000 }, + ], + delays: { rest: 5_000, core: 0, graphql: 0 }, + page: { pullRequests: [], cursor: undefined, hasNextPage: false }, + }); + } finally { + service.dispose(); + } + })); + test('a retained bootstrap cooldown does not delay a peer with an already-resolved identity', () => runWithFakedTimers({}, async () => { const service = setup({ fetch: async () => new Response('{"id":101}') }); try { From 678d0d87a02696c2b93c52cef30d579a3c7fb0e8 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Sat, 3 Oct 2026 23:58:26 -0700 Subject: [PATCH 09/49] agentHost: tell HydraFusion phase chats apart from subagents in telemetry (#339560) * agentHost: tell HydraFusion phase chats apart from subagents in telemetry Each HydraFusion phase opens a child chat through a synthetic subagent_started signal, so the host reported every phase as a subagent turn with no model and no task model source. That inflated subagent launch counts and lowered the share of subagents with a known model. Add a bounded subagentKind property (task | fusionPhase) to the events that decide isSubagentSession per chat, and report the phase's model on the phase chat's turn and tool-call events. Fixes #339326 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: trim comments in Fusion phase telemetry change Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/agent.ts | 7 ++ .../node/agentHostInputRequestTracker.ts | 4 +- .../node/agentHostTelemetryReporter.ts | 77 ++++++++++++++----- .../node/agentHostToolCallTracker.ts | 7 +- .../agentHost/node/agentHostTurnTracker.ts | 15 +++- .../agentHost/node/agentSideEffects.ts | 66 ++++++++++------ .../node/copilot/copilotAgentSession.ts | 4 +- .../node/copilot/copilotFusionProgress.ts | 5 ++ .../node/copilot/copilotTodoStoreTelemetry.ts | 13 ++-- .../node/agentHostInputRequestTracker.test.ts | 26 ++++--- .../node/agentHostToolCallTelemetry.test.ts | 26 ++++++- .../test/node/agentHostTurnTelemetry.test.ts | 73 +++++++++++++++++- .../test/node/copilotAgentSession.test.ts | 28 +++++++ 13 files changed, 286 insertions(+), 65 deletions(-) diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index d4ad96adc8e5..8eda7dede66d 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1046,6 +1046,9 @@ export interface IAgentToolPendingConfirmationSignal { export type AgentSubagentTaskModelSource = 'task_argument' | 'subagent_configuration' | 'custom_agent_definition' | 'unset'; +/** `task` is a delegated subagent; `fusionPhase` is a presentation-only chat for one HydraFusion phase of the parent turn. */ +export type AgentSubagentKind = 'task' | 'fusionPhase'; + /** * A subagent was spawned by a tool call. The host creates a child session * silently and routes subsequent inner-tool events to it. @@ -1061,6 +1064,10 @@ export interface IAgentSubagentStartedSignal { readonly agentDisplayName: string; readonly agentDescription?: string; readonly taskModelSource?: AgentSubagentTaskModelSource; + /** Absent means `task`. */ + readonly subagentKind?: AgentSubagentKind; + /** For telemetry, when the chat reports no usage of its own. */ + readonly model?: string; /** * The spawning Task tool's short (typically 3-5 word) `description` * input, e.g. "Review package.json structure". Distinct from diff --git a/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts b/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts index 819a6353b5d6..924fd441f3e7 100644 --- a/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { StopWatch } from '../../../base/common/stopwatch.js'; -import type { IAgentTelemetryContext } from '../common/agent.js'; +import type { AgentSubagentKind, IAgentTelemetryContext } from '../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { ChatInputRequestPurpose, readChatInputRequestPurpose } from '../common/meta/agentChatInputRequestMeta.js'; import type { ChatInputCompletedAction } from '../common/state/sessionActions.js'; @@ -31,6 +31,7 @@ export class AgentHostInputRequestTracker { private readonly _stopWatchFactory: () => Pick = () => StopWatch.create(true), private readonly _getClientContext: (session: string, turnId: string) => IAgentHostClientTelemetryContext | undefined = () => undefined, private readonly _getTelemetryContext: (session: string, turnId: string) => IAgentTelemetryContext | undefined = () => undefined, + private readonly _getSubagentKind: (session: string, turnId: string) => AgentSubagentKind | undefined = () => undefined, ) { } inputRequested(provider: string, session: string, turnId: string, request: ChatInputRequest): void { @@ -85,6 +86,7 @@ export class AgentHostInputRequestTracker { telemetryContext: this._getTelemetryContext(timing.session, timing.turnId), provider: timing.provider, session: timing.session, + subagentKind: this._getSubagentKind(timing.session, timing.turnId), requestId: timing.turnId, questionCount: questions.length, answeredCount, diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 96fbe237bb69..de29a17066f1 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -8,7 +8,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { TelemetryTrustedValue } from '../../telemetry/common/telemetryUtils.js'; import { hash } from '../../../base/common/hash.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import { AgentSession, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type AgentTurnProviderSessionState, type IAgentTurnDiagnosticSnapshot, type IAgentTokenUsageSummary, type IAgentTelemetryContext } from '../common/agent.js'; +import { AgentSession, type AgentSubagentKind, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type AgentTurnProviderSessionState, type IAgentTurnDiagnosticSnapshot, type IAgentTokenUsageSummary, type IAgentTelemetryContext } from '../common/agent.js'; import { isReasoningEffortLevel } from '../common/reasoningEffort.js'; import type { SessionMode } from '../common/agentHostSchema.js'; import { getTelemetryChatSessionId } from '../common/agentTelemetryCorrelation.js'; @@ -80,6 +80,10 @@ export interface IAgentHostEventTelemetry extends IAgentHostInitiatorTelemetry, export type IAgentHostEventClassification = IAgentHostInitiatorClassification & IAgentHostCopilotSkuClassification; +export type IAgentHostSubagentKindClassification = { + subagentKind?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'What the subagent chat represents, set only when isSubagentSession is true: task for a delegated subagent, or fusionPhase for a presentation-only HydraFusion phase chat that shows part of the parent turn.' }; +}; + export interface IAgentHostExecutionModeChangedEvent extends IAgentHostEventTelemetry { provider: string; agentSessionId: string; @@ -254,6 +258,7 @@ export interface IAgentHostTurnCompletedEvent extends IAgentHostEventTelemetry, agentSessionId: string; chatSessionId: string; isSubagentSession: boolean; + subagentKind?: AgentSubagentKind; turnId: string; parentTurnId: string | undefined; parentToolCallId: string | undefined; @@ -301,7 +306,7 @@ export interface IAgentHostTurnCompletedEvent extends IAgentHostEventTelemetry, modelCallCount: number; } -export type IAgentHostTurnCompletedClassification = IAgentHostEventClassification & CodexAccountTelemetryClassification & { +export type IAgentHostTurnCompletedClassification = IAgentHostEventClassification & CodexAccountTelemetryClassification & IAgentHostSubagentKindClassification & { hostRootTurnOrdinal?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'One-based root-turn ordinal over the agent host process lifetime, captured at turn start and excluding subagent turns.' }; hostProcessAgeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Agent host process age in milliseconds captured at root turn start, not completion.' }; titleGenerationStrategy?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The effective persisted automatic title generation strategy captured before sending the root turn: activeAgent, utility, or deferred.' }; @@ -363,6 +368,7 @@ export interface IAgentHostTurnFailedEvent extends IAgentHostEventTelemetry { agentSessionId: string; chatSessionId: string; isSubagentSession: boolean; + subagentKind?: AgentSubagentKind; turnId: string; messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; failureStage: AgentHostTurnFailureStage; @@ -375,7 +381,7 @@ export interface IAgentHostTurnFailedEvent extends IAgentHostEventTelemetry { callstack: string | undefined; } -export type IAgentHostTurnFailedClassification = IAgentHostEventClassification & { +export type IAgentHostTurnFailedClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the failed agent host turn.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; @@ -409,6 +415,7 @@ export interface IAgentHostTurnCompletedReport extends IAgentHostTurnAttributedR titleGenerationStrategy?: AutomaticTitleGenerationStrategy; provider: string; session: string; + subagentKind?: AgentSubagentKind; turnId: string; parentTurnId: string | undefined; parentToolCallId: string | undefined; @@ -452,6 +459,7 @@ interface IRequestTokenUsageEvent extends IAgentHostEventTelemetry, Omit; selectedModel?: string | TelemetryTrustedValue; result: AgentHostTurnResult; @@ -461,7 +469,7 @@ interface IRequestTokenUsageEvent extends IAgentHostEventTelemetry, Omit & IAgentHostEventClassification & { +export type IAgentHostToolInvokedClassification = Omit & IAgentHostEventClassification & IAgentHostSubagentKindClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host provider that invoked the tool.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat identifier within the Agent Host session.' }; @@ -742,9 +756,10 @@ export interface IAgentHostAskQuestionsToolInvokedEvent extends IAgentHostEventT provider: string; agentSessionId: string; isSubagentSession: boolean; + subagentKind?: AgentSubagentKind; } -export type IAgentHostAskQuestionsToolInvokedClassification = IAgentHostEventClassification & { +export type IAgentHostAskQuestionsToolInvokedClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & { requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the current request turn.' }; questionCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The total number of questions asked' }; answeredCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of questions that were answered' }; @@ -763,6 +778,7 @@ export type IAgentHostAskQuestionsToolInvokedClassification = IAgentHostEventCla export interface IAgentHostAskQuestionsToolInvokedReport extends IAgentHostTurnAttributedReport { provider: string; session: string; + subagentKind?: AgentSubagentKind; requestId: string; questionCount: number; answeredCount: number; @@ -945,6 +961,7 @@ export interface IAgentHostToolCallStalledEvent extends IAgentHostEventTelemetry provider: string; agentSessionId: string; isSubagentSession: boolean; + subagentKind?: AgentSubagentKind; blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution | SessionInputRequestKind.ToolAuthentication; toolCallId: string; toolId: string; @@ -955,7 +972,7 @@ export interface IAgentHostToolCallStalledEvent extends IAgentHostEventTelemetry export type AgentHostExecutorClientConnectionState = 'connected' | 'disconnected' | 'unknown'; -export type IAgentHostToolCallStalledClassification = IAgentHostEventClassification & { +export type IAgentHostToolCallStalledClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the stalled agent host tool call.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the stalled tool call belongs to a subagent session.' }; @@ -972,6 +989,7 @@ export type IAgentHostToolCallStalledClassification = IAgentHostEventClassificat export interface IAgentHostToolCallStalledReport extends IAgentHostTurnAttributedReport { provider: string; session: string; + subagentKind?: AgentSubagentKind; blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution | SessionInputRequestKind.ToolAuthentication; toolCallId: string; toolId: string; @@ -984,6 +1002,7 @@ export interface IAgentHostStalledToolCallCompletedEvent extends IAgentHostEvent provider: string; agentSessionId: string; isSubagentSession: boolean; + subagentKind?: AgentSubagentKind; blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution | SessionInputRequestKind.ToolAuthentication; toolCallId: string; toolId: string; @@ -994,7 +1013,7 @@ export interface IAgentHostStalledToolCallCompletedEvent extends IAgentHostEvent timeAfterStallMs: number; } -export type IAgentHostStalledToolCallCompletedClassification = IAgentHostEventClassification & { +export type IAgentHostStalledToolCallCompletedClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the completed agent host tool call.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the completed tool call belongs to a subagent session.' }; @@ -1013,6 +1032,7 @@ export type IAgentHostStalledToolCallCompletedClassification = IAgentHostEventCl export interface IAgentHostStalledToolCallCompletedReport extends IAgentHostTurnAttributedReport { provider: string; session: string; + subagentKind?: AgentSubagentKind; blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution | SessionInputRequestKind.ToolAuthentication; toolCallId: string; toolId: string; @@ -1081,6 +1101,10 @@ export function toInitiatorTelemetry(clientContext: IAgentHostClientTelemetryCon export const IAgentHostTelemetryReporter = createDecorator('agentHostTelemetryReporter'); +export function toSubagentKindTelemetry(isSubagent: boolean, kind: AgentSubagentKind | undefined): { subagentKind?: AgentSubagentKind } { + return isSubagent ? { subagentKind: kind ?? 'task' } : {}; +} + export class AgentHostTelemetryReporter { declare readonly _serviceBrand: undefined; @@ -1479,8 +1503,9 @@ export class AgentHostTelemetryReporter { restricted.sendInternalMSFTTelemetryEventForContext(context, 'request.repoInfo', internalMultiplexedProperties, measurements); } - requestTokenUsage(report: { clientContext: IAgentHostClientTelemetryContext; telemetryContext?: IAgentTelemetryContext; provider: string; session: string; requestId: string; parentTurnId?: string; parentToolCallId?: string; selectedModel?: string; selectedModelTelemetryKind?: AgentHostModelTelemetryKind; modelTelemetryKind?: AgentHostModelTelemetryKind; result: AgentHostTurnResult; summary: IAgentTokenUsageSummary }): void { + requestTokenUsage(report: { clientContext: IAgentHostClientTelemetryContext; telemetryContext?: IAgentTelemetryContext; provider: string; session: string; requestId: string; parentTurnId?: string; parentToolCallId?: string; subagentKind?: AgentSubagentKind; selectedModel?: string; selectedModelTelemetryKind?: AgentHostModelTelemetryKind; modelTelemetryKind?: AgentHostModelTelemetryKind; result: AgentHostTurnResult; summary: IAgentTokenUsageSummary }): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session); const { model: usageModel, reasoningEffort, ...summary } = report.summary; const model = usageModel === 'auto' ? 'unknown' : toTelemetryModel(usageModel, report.modelTelemetryKind) ?? 'unknown'; const selectedModel = toTelemetryModel(report.selectedModel, report.selectedModelTelemetryKind); @@ -1495,7 +1520,8 @@ export class AgentHostTelemetryReporter { requestId: report.requestId, parentTurnId: report.parentTurnId, parentToolCallId: report.parentToolCallId, - isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), + isSubagentSession: isSubagent, + ...toSubagentKindTelemetry(isSubagent, report.subagentKind), model, ...(selectedModel !== undefined ? { selectedModel } : {}), ...(effort !== undefined ? { reasoningEffort: effort } : {}), @@ -1511,6 +1537,7 @@ export class AgentHostTelemetryReporter { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; const chatSessionId = getTelemetryChatSessionId(report.session); const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session); + const subagentKindTelemetry = toSubagentKindTelemetry(isSubagent, report.subagentKind); const model = toTelemetryModel(report.model, report.modelTelemetryKind); const providerStages = toProviderStageMeasurements(report.providerStageDurationsMs); this._otelService?.emitTurnTiming({ @@ -1546,6 +1573,7 @@ export class AgentHostTelemetryReporter { agentSessionId: AgentSession.id(session), chatSessionId, isSubagentSession: isSubagent, + ...subagentKindTelemetry, turnId: report.turnId, parentTurnId: report.parentTurnId, parentToolCallId: report.parentToolCallId, @@ -1592,6 +1620,7 @@ export class AgentHostTelemetryReporter { agentSessionId: AgentSession.id(session), chatSessionId, isSubagentSession: isSubagent, + ...subagentKindTelemetry, turnId: report.turnId, messageOriginKind: report.messageOriginKind, failureStage: report.failure.stage, @@ -1613,6 +1642,7 @@ export class AgentHostTelemetryReporter { */ turnHung(report: IAgentHostTurnHungReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session); this._telemetryService.publicLog2('agentHost.turnHung', { ...toInitiatorTelemetry(report.clientContext), ...report.telemetryContext, @@ -1620,7 +1650,8 @@ export class AgentHostTelemetryReporter { provider: report.provider, agentSessionId: AgentSession.id(session), chatSessionId: getTelemetryChatSessionId(report.session), - isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), + isSubagentSession: isSubagent, + ...toSubagentKindTelemetry(isSubagent, report.subagentKind), turnId: report.turnId, messageOriginKind: report.messageOriginKind, hangReason: report.hangReason, @@ -1650,13 +1681,15 @@ export class AgentHostTelemetryReporter { /** Paired recovery event for a turn previously reported by {@link turnHung}. */ hungTurnCompleted(report: IAgentHostHungTurnCompletedReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session); this._telemetryService.publicLog2('agentHost.hungTurnCompleted', { ...toInitiatorTelemetry(report.clientContext), ...report.telemetryContext, provider: report.provider, agentSessionId: AgentSession.id(session), chatSessionId: getTelemetryChatSessionId(report.session), - isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), + isSubagentSession: isSubagent, + ...toSubagentKindTelemetry(isSubagent, report.subagentKind), turnId: report.turnId, messageOriginKind: report.messageOriginKind, hangReason: report.hangReason, @@ -1672,6 +1705,7 @@ export class AgentHostTelemetryReporter { // previously emitted by `CopilotAgentSession`). Action signals are keyed // by their chat-channel URI, so normalize it back to the session URI. const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session); this._telemetryService.publicLog2('languageModelToolInvoked', { ...toInitiatorTelemetry(report.clientContext), ...report.telemetryContext, @@ -1694,7 +1728,8 @@ export class AgentHostTelemetryReporter { result: report.result, agentSessionId: AgentSession.id(session), chatSessionId: getTelemetryChatSessionId(report.session), - isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), + isSubagentSession: isSubagent, + ...toSubagentKindTelemetry(isSubagent, report.subagentKind), toolId: report.toolId, toolExtensionId: undefined, toolSourceKind: report.toolSourceKind, @@ -1713,6 +1748,7 @@ export class AgentHostTelemetryReporter { askQuestionsToolInvoked(report: IAgentHostAskQuestionsToolInvokedReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session); this._telemetryService.publicLog2('askQuestionsToolInvoked', { ...toInitiatorTelemetry(report.clientContext), ...report.telemetryContext, @@ -1726,18 +1762,21 @@ export class AgentHostTelemetryReporter { duration: report.duration, provider: report.provider, agentSessionId: AgentSession.id(session), - isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), + isSubagentSession: isSubagent, + ...toSubagentKindTelemetry(isSubagent, report.subagentKind), }); } toolCallStalled(report: IAgentHostToolCallStalledReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session); this._telemetryService.publicLog2('agentHost.toolCallStalled', { ...toInitiatorTelemetry(report.clientContext), ...report.telemetryContext, provider: report.provider, agentSessionId: AgentSession.id(session), - isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), + isSubagentSession: isSubagent, + ...toSubagentKindTelemetry(isSubagent, report.subagentKind), blockerKind: report.blockerKind, toolCallId: report.toolCallId, toolId: report.toolId, @@ -1749,12 +1788,14 @@ export class AgentHostTelemetryReporter { stalledToolCallCompleted(report: IAgentHostStalledToolCallCompletedReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session); this._telemetryService.publicLog2('agentHost.stalledToolCallCompleted', { ...toInitiatorTelemetry(report.clientContext), ...report.telemetryContext, provider: report.provider, agentSessionId: AgentSession.id(session), - isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), + isSubagentSession: isSubagent, + ...toSubagentKindTelemetry(isSubagent, report.subagentKind), blockerKind: report.blockerKind, toolCallId: report.toolCallId, toolId: report.toolId, diff --git a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts index 2e46f44ebc87..a8ca6c27a065 100644 --- a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts @@ -8,7 +8,7 @@ import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; -import type { IAgentTelemetryContext } from '../common/agent.js'; +import type { AgentSubagentKind, IAgentTelemetryContext } from '../common/agent.js'; import type { McpServerSource } from '../common/meta/mcpCustomizationMeta.js'; import { SessionInputRequestKind, type SessionToolAuthenticationRequest, type SessionToolClientExecutionRequest, type SessionToolConfirmationRequest } from '../common/state/protocol/state.js'; import { type ToolCallContributor, type ToolCallResult } from '../common/state/sessionState.js'; @@ -56,6 +56,7 @@ interface IToolCallTiming { modelResolvedFromUsage: boolean; readonly clientContext: IAgentHostClientTelemetryContext | undefined; readonly telemetryContext: IAgentTelemetryContext | undefined; + readonly subagentKind: AgentSubagentKind | undefined; } interface IStalledToolCall { @@ -114,6 +115,7 @@ export class AgentHostToolCallTracker extends Disposable { modelResolvedFromUsage: resolvedModel !== undefined, clientContext: this._turnTracker.getClientTelemetryContext(session, turnId), telemetryContext: this._turnTracker.getTelemetryContext(session, turnId), + subagentKind: this._turnTracker.getSubagentKind(session, turnId), }); } @@ -174,6 +176,7 @@ export class AgentHostToolCallTracker extends Disposable { telemetryContext: timing.telemetryContext, provider: timing.provider, session: timing.session, + subagentKind: timing.subagentKind, turnId: timing.turnId, toolId: timing.toolId, toolSourceKind: timing.toolSourceKind, @@ -203,6 +206,7 @@ export class AgentHostToolCallTracker extends Disposable { telemetryContext: timing.telemetryContext, provider: timing.provider, session: timing.session, + subagentKind: timing.subagentKind, blockerKind: stalled.blockerKind, toolCallId, toolId: timing.toolId, @@ -233,6 +237,7 @@ export class AgentHostToolCallTracker extends Disposable { telemetryContext: this._toolCalls.get(toolCallKey)?.telemetryContext, provider, session, + subagentKind: this._toolCalls.get(toolCallKey)?.subagentKind, blockerKind: request.kind, toolCallId: request.toolCall.toolCallId, toolId: request.toolCall.toolName, diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index 30faf1a67764..d037acf7619b 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -10,7 +10,7 @@ import { Disposable, DisposableMap, toDisposable } from '../../../base/common/li import { StopWatch } from '../../../base/common/stopwatch.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { URI } from '../../../base/common/uri.js'; -import type { AgentModelCallFinishedOutcome, AgentSubagentTaskModelSource, IAgent, IAgentTelemetryContext, IAgentTokenUsageSummary, IAgentTurnDiagnosticSnapshot, IAgentTurnTokenUsage } from '../common/agent.js'; +import type { AgentModelCallFinishedOutcome, AgentSubagentKind, AgentSubagentTaskModelSource, IAgent, IAgentTelemetryContext, IAgentTokenUsageSummary, IAgentTurnDiagnosticSnapshot, IAgentTurnTokenUsage } from '../common/agent.js'; import type { SessionMode } from '../common/agentHostSchema.js'; import { createUnknownAgentHostClientTelemetryContext, type AgentHostProviderSendStage, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder, type IAgentProviderTurnTelemetryContext } from '../common/agentHostTelemetry.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; @@ -82,6 +82,7 @@ interface ITurnTiming { /** Who produced the message that started the turn, when known. */ readonly messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; readonly subagentTaskModelSource: AgentSubagentTaskModelSource | undefined; + readonly subagentKind: AgentSubagentKind | undefined; readonly clientContext: IAgentHostClientTelemetryContext; telemetryContext: IAgentTelemetryContext | undefined; readonly providerTelemetryContext: IAgentProviderTurnTelemetryContext | undefined; @@ -209,7 +210,7 @@ export class AgentHostTurnTracker extends Disposable { })); } - turnStarted(agent: IAgent, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: 'default' | 'auto' | 'explicit', permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), initiatorClientId?: string, parentTurnId?: string, parentToolCallId?: string, messageOriginKind?: AgentHostMessageOriginTelemetryKind, subagentTaskModelSource?: AgentSubagentTaskModelSource, providerChat = URI.parse(session)): void { + turnStarted(agent: IAgent, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: 'default' | 'auto' | 'explicit', permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), initiatorClientId?: string, parentTurnId?: string, parentToolCallId?: string, messageOriginKind?: AgentHostMessageOriginTelemetryKind, subagentTaskModelSource?: AgentSubagentTaskModelSource, providerChat = URI.parse(session), subagentKind?: AgentSubagentKind): void { const key = this._key(session, turnId); let rootTiming = this._rootTurnTimings.get(key); const isNewRootTurn = !parentTurnId && !isSubagentChatUri(session) && !isSubagentSession(parseChatUri(session)?.session ?? session) && !rootTiming; @@ -239,6 +240,7 @@ export class AgentHostTurnTracker extends Disposable { interactionMode, messageOriginKind, subagentTaskModelSource, + subagentKind, clientContext, telemetryContext: agent.getTelemetryContext?.(), providerTelemetryContext: captureProviderTurnTelemetryContext(agent), @@ -643,6 +645,10 @@ export class AgentHostTurnTracker extends Disposable { return this._turnTimings.get(this._key(session, turnId))?.providerTelemetryContext; } + getSubagentKind(session: string, turnId: string): AgentSubagentKind | undefined { + return this._turnTimings.get(this._key(session, turnId))?.subagentKind; + } + getMessageOriginKind(session: string, turnId: string): AgentHostMessageOriginTelemetryKind | undefined { return this._turnTimings.get(this._key(session, turnId))?.messageOriginKind; } @@ -719,6 +725,7 @@ export class AgentHostTurnTracker extends Disposable { interactionMode: timing.interactionMode, messageOriginKind: timing.messageOriginKind, subagentTaskModelSource: timing.subagentTaskModelSource, + subagentKind: timing.subagentKind, failure, isMultiRoot: workspace?.isMultiRoot ?? false, folderCount: workspace?.folderCount ?? 0, @@ -739,6 +746,7 @@ export class AgentHostTurnTracker extends Disposable { provider: timing.agent.id, session: timing.session, turnId, + subagentKind: timing.subagentKind, messageOriginKind: timing.messageOriginKind, hangReason: timing.lastHangReason, result, @@ -752,7 +760,7 @@ export class AgentHostTurnTracker extends Disposable { this._reporter.requestTokenUsage({ clientContext: timing.clientContext, provider: timing.agent.id, telemetryContext: timing.telemetryContext, - session, requestId: turnId, parentTurnId: timing.parentTurnId, parentToolCallId: timing.parentToolCallId, + session, requestId: turnId, parentTurnId: timing.parentTurnId, parentToolCallId: timing.parentToolCallId, subagentKind: timing.subagentKind, selectedModel: timing.selectedModel, selectedModelTelemetryKind: timing.selectedModelTelemetryKind, modelTelemetryKind: summary.model ? getModelTelemetryContext(timing.agent, summary.model).modelTelemetryKind : undefined, result, summary, @@ -847,6 +855,7 @@ export class AgentHostTurnTracker extends Disposable { provider: timing.agent.id, session: timing.session, turnId: timing.turnId, + subagentKind: timing.subagentKind, messageOriginKind: timing.messageOriginKind, hangReason, hadAnyProgress: timing.lastActivityKind !== TURN_ACTIVITY_NONE, diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index a18df0b42c8f..a65542ef7b6a 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -25,7 +25,7 @@ import { IAgentHostChatContributions, type ISendTurnMessageOptions } from '../co import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { isRenameChatTool } from '../common/serverToolNames.js'; import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; -import { AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentToolPendingConfirmationSignal, type AgentSubagentTaskModelSource, type IAgentModelCallCompletedSignal, type IAgentModelCallFinishedSignal } from '../common/agent.js'; +import { AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentToolPendingConfirmationSignal, type AgentSubagentKind, type AgentSubagentTaskModelSource, type IAgentModelCallCompletedSignal, type IAgentModelCallFinishedSignal, type IAgentSubagentStartedSignal } from '../common/agent.js'; import { isPresentationOnlyToolCall, readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isAgentMergeMessage } from '../common/meta/agentMergeMessageMeta.js'; import { readAgentPermissionResponseMeta } from '../common/meta/agentPermissionResponseMeta.js'; @@ -172,6 +172,8 @@ interface ISubagentSessionRef { readonly chatUri: ProtocolURI; readonly turnStopWatch: StopWatch; readonly taskModelSource: AgentSubagentTaskModelSource | undefined; + readonly subagentKind: AgentSubagentKind | undefined; + readonly model: string | undefined; } interface ISubagentParentTurnTelemetryContext { @@ -311,6 +313,7 @@ export class AgentSideEffects extends Disposable { undefined, (session, turnId) => this._turnTracker.getClientTelemetryContext(session, turnId), (session, turnId) => this._turnTracker.getTelemetryContext(session, turnId), + (session, turnId) => this._turnTracker.getSubagentKind(session, turnId), ); this._permissionManager = this._register(this._instantiationService.createInstance(SessionPermissionManager, this._stateManager, {})); this._register(this._stateManager.onDidSnapshotDefaultChatTitle(event => this._persistDefaultChatTitleSnapshot(event.session, event.chat, event.title))); @@ -674,7 +677,7 @@ export class AgentSideEffects extends Disposable { return; } if (signal.kind === 'subagent_started') { - this._handleSubagentStarted(signal.chat.toString(), signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription, signal.taskPrompt, signal.parentToolCallId, signal.taskModelSource); + this._handleSubagentStarted(signal); this._drainPendingSubagentSignals(signal.chat.toString(), signal.toolCallId); return; } @@ -923,10 +926,8 @@ export class AgentSideEffects extends Disposable { if (!isSubagentChatUri(sessionKey)) { this._turnTracker.updateBilledNanoAiu(sessionKey, action.turnId, usageMeta.copilotUsage?.totalNanoAiu); } - if (action.usage.model && agent) { - const modelContext = getModelTelemetryContext(agent, action.usage.model); - this._turnTracker.updateModel(sessionKey, action.turnId, modelContext.model, modelContext.modelTelemetryKind); - this._toolCallTracker.updateTurnModel(sessionKey, action.turnId, modelContext.model, modelContext.modelTelemetryKind); + if (agent) { + this._updateTurnModel(agent, sessionKey, action.turnId, action.usage.model); } } @@ -1171,25 +1172,18 @@ export class AgentSideEffects extends Disposable { * before this runs, so this only drives the turn/tracking/parent content * — it does not add the chat. * - * `chatURI` is always the agent's top-level chat: the subagent is + * `signal.chat` is always the agent's top-level chat: the subagent is * registered (and inner events routed) under it because inner-tool - * signals carry the top-level chat as their resource. `spawningToolParentId`, + * signals carry the top-level chat as their resource. `signal.parentToolCallId`, * when set, is the tool call one level up from the spawning `toolCallId` * — the tool call in whose (subagent) chat the spawning tool lives — and * is used to route the discovery content block to that immediate parent * chat. Since subagent chats are flat (keyed off the root session), this * one-hop reference resolves the parent chat at any nesting depth. */ - private _handleSubagentStarted( - chatURI: ProtocolURI, - toolCallId: string, - agentName: string, - agentDisplayName: string, - agentDescription?: string, - taskPrompt?: string, - spawningToolParentId?: string, - taskModelSource?: AgentSubagentTaskModelSource, - ): void { + private _handleSubagentStarted(signal: IAgentSubagentStartedSignal): void { + const chatURI = signal.chat.toString(); + const { toolCallId, agentName, agentDisplayName, agentDescription, taskPrompt, parentToolCallId: spawningToolParentId, taskModelSource, subagentKind, model } = signal; const parentSessionUri = parseRequiredSessionUriFromChatUri(chatURI); const subagentChatUri = buildSubagentChatUri(parentSessionUri, toolCallId); const immediateParentChatUri = spawningToolParentId @@ -1199,6 +1193,9 @@ export class AgentSideEffects extends Disposable { const existing = this._subagentChats.get(chatURI, toolCallId); if (existing) { + if (model && model !== existing.model) { + this._subagentChats.set({ ...existing, model }, chatURI, toolCallId); + } this._resumeSubagentSession(chatURI, toolCallId, taskPrompt ? { text: taskPrompt, origin: { kind: MessageKind.User } } : undefined, immediateParentChatUri); return; } @@ -1219,11 +1216,12 @@ export class AgentSideEffects extends Disposable { const agent = this._options.getAgent(parentSessionUri); if (agent) { const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(parentSessionUri)?.config); - this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind, taskModelSource, URI.parse(chatURI)); + this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind, taskModelSource, URI.parse(chatURI), subagentKind); this._turnTracker.setCurrentStage(subagentChatUri, turnId, 'provider'); + this._updateTurnModel(agent, subagentChatUri, turnId, model); } - this._subagentChats.set({ parentChatUri: chatURI, immediateParentChatUri, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri, turnStopWatch: StopWatch.create(false), taskModelSource }, chatURI, toolCallId); + this._subagentChats.set({ parentChatUri: chatURI, immediateParentChatUri, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri, turnStopWatch: StopWatch.create(false), taskModelSource, subagentKind, model }, chatURI, toolCallId); // Dispatch the discovery content on the spawning tool call's own chat; the top-level chat is a no-op when nested. if (parentTurnId) { @@ -1295,14 +1293,25 @@ export class AgentSideEffects extends Disposable { const agent = this._options.getAgent(subagent.sessionUri); if (agent) { const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(subagent.sessionUri)?.config); - this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind, subagent.taskModelSource, URI.parse(parentChatURI)); + this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind, subagent.taskModelSource, URI.parse(parentChatURI), subagent.subagentKind); this._turnTracker.setCurrentStage(subagent.chatUri, turnId, 'provider'); + this._updateTurnModel(agent, subagent.chatUri, turnId, subagent.model); } this._subagentChats.set({ ...subagent, immediateParentChatUri: correlatedParentChatUri, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId); } + private _updateTurnModel(agent: IAgent, chat: ProtocolURI, turnId: string, model: string | undefined): void { + if (!model) { + return; + } + const modelContext = getModelTelemetryContext(agent, model); + this._turnTracker.updateModel(chat, turnId, modelContext.model, modelContext.modelTelemetryKind); + this._toolCallTracker.updateTurnModel(chat, turnId, modelContext.model, modelContext.modelTelemetryKind); + } + private _getSubagentParentTurnTelemetryContext(immediateParentChatUri: ProtocolURI | undefined, fallbackParentChatUri: ProtocolURI): ISubagentParentTurnTelemetryContext { - const parentChatUri = immediateParentChatUri ?? fallbackParentChatUri; + immediateParentChatUri = immediateParentChatUri && this._turnOwningChat(immediateParentChatUri); + const parentChatUri = immediateParentChatUri ?? this._turnOwningChat(fallbackParentChatUri); const parentTurnId = this._stateManager.getActiveTurnId(parentChatUri); const parentSessionUri = parseRequiredSessionUriFromChatUri(parentChatUri); const parentMessageOriginKind = parentTurnId ? this._turnTracker.getMessageOriginKind(parentChatUri, parentTurnId) : undefined; @@ -1317,6 +1326,19 @@ export class AgentSideEffects extends Disposable { }; } + /** A Fusion phase chat only presents part of its parent's turn, so that parent owns the work done in it. */ + private _turnOwningChat(chatUri: ProtocolURI): ProtocolURI { + if (!isSubagentChatUri(chatUri)) { + return chatUri; + } + for (const subagent of this._subagentChats.values()) { + if (subagent.chatUri === chatUri && subagent.subagentKind === 'fusionPhase') { + return this._turnOwningChat(subagent.immediateParentChatUri ?? subagent.parentChatUri); + } + } + return chatUri; + } + /** * Cancels child executions, retaining reusable routing only when requested by a parent turn cancellation. */ diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index f7f57f0a7ebf..de6888868ab5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -6666,7 +6666,7 @@ export class CopilotAgentSession extends Disposable { const telemetrySession = parentToolCallId ? URI.parse(buildSubagentSessionUri(this._storageUri.toString(), parentToolCallId)) : this.resourceUri; - reportCopilotTodoStoreOperation(this._telemetryService, telemetrySession, e.data.toolCallId, tracked.toolName, tracked.parameters, this._currentTurn.value?.clientContext); + reportCopilotTodoStoreOperation(this._telemetryService, telemetrySession, e.data.toolCallId, tracked.toolName, tracked.parameters, this._currentTurn.value?.clientContext, parentToolCallId && this._fusionPhaseLabels.has(parentToolCallId) ? 'fusionPhase' : undefined); } this._logService.info(`[Copilot:${sessionId}] Tool completed: ${e.data.toolCallId}`); this._reportToolApprovalIfNoPermission(e.data.toolCallId); @@ -8202,6 +8202,8 @@ export class CopilotAgentSession extends Disposable { agentName: COPILOT_FUSION_PHASE_AGENT_NAME, agentDisplayName: label, taskDescription: label, + subagentKind: 'fusionPhase', + model: this._fusionProgress.getPhaseModel(toolCallId), }); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotFusionProgress.ts b/src/vs/platform/agentHost/node/copilot/copilotFusionProgress.ts index 6c06c986a513..7e5f4d3ddc73 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotFusionProgress.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotFusionProgress.ts @@ -149,6 +149,11 @@ export class CopilotFusionProgress { return this._phase ? getFusionPhaseToolCallId(this._phase.fusionId, this._phase.phaseId) : undefined; } + getPhaseModel(toolCallId: string): string | undefined { + const toolCall = this._phaseTools.get(toolCallId); + return toolCall ? readToolCallMeta(toolCall).fusionPhase?.model : undefined; + } + reset(): void { this._milestones.clear(); this._finishedFusions.clear(); diff --git a/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts b/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts index 44935508ccba..264d7e7a835f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts @@ -5,10 +5,10 @@ import { URI } from '../../../../base/common/uri.js'; import type { ITelemetryService } from '../../../telemetry/common/telemetry.js'; -import { AgentSession } from '../../common/agent.js'; +import { AgentSession, type AgentSubagentKind } from '../../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { isSubagentSession } from '../../common/state/sessionState.js'; -import { toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js'; +import { toInitiatorTelemetry, toSubagentKindTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry, type IAgentHostSubagentKindClassification } from '../agentHostTelemetryReporter.js'; type TodoStoreOperation = 'read' | 'write' | 'mixed'; type TodoStoreTarget = 'todos' | 'todo_deps' | 'both'; @@ -20,9 +20,10 @@ type TodoStoreOperationEvent = IAgentHostEventTelemetry & { provider: string; agentSessionId: string; isSubagentSession: boolean; + subagentKind?: AgentSubagentKind; }; -type TodoStoreOperationClassification = IAgentHostEventClassification & { +type TodoStoreOperationClassification = IAgentHostEventClassification & IAgentHostSubagentKindClassification & { operation: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the SQL operation read from, wrote to, or both read from and wrote to todo storage.' }; target: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the SQL operation referenced todo items, todo dependencies, or both.' }; toolCallId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the SQL tool call, used to correlate with generic tool telemetry.' }; @@ -43,19 +44,21 @@ interface ISqlToken { readonly kind: 'identifier' | 'punctuation'; } -export function reportCopilotTodoStoreOperation(telemetryService: ITelemetryService, session: URI, toolCallId: string, toolName: string, toolInput: Readonly> | undefined, clientContext?: IAgentHostClientTelemetryContext): void { +export function reportCopilotTodoStoreOperation(telemetryService: ITelemetryService, session: URI, toolCallId: string, toolName: string, toolInput: Readonly> | undefined, clientContext?: IAgentHostClientTelemetryContext, subagentKind?: AgentSubagentKind): void { const operation = getCopilotTodoStoreOperationData(toolName, toolInput); if (!operation) { return; } + const isSubagent = isSubagentSession(session); telemetryService.publicLog2('todoStoreOperation', { ...toInitiatorTelemetry(clientContext), ...operation, toolCallId, provider: session.scheme, agentSessionId: AgentSession.id(session), - isSubagentSession: isSubagentSession(session), + isSubagentSession: isSubagent, + ...toSubagentKindTelemetry(isSubagent, subagentKind), }); } diff --git a/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts b/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts index 441151b41ab8..9043c7be8ea3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; -import { AgentSession, type IAgentTelemetryContext } from '../../common/agent.js'; +import { AgentSession, type AgentSubagentKind, type IAgentTelemetryContext } from '../../common/agent.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; @@ -43,11 +43,11 @@ suite('AgentHostInputRequestTracker', () => { const rootChat = buildDefaultChatUri(rootSession); const subagentChat = buildSubagentChatUri(rootSession, 'subagent-tool'); - function createTracker(clientContext?: IAgentHostClientTelemetryContext, telemetryContext?: IAgentTelemetryContext): { telemetry: CapturingTelemetryService; tracker: AgentHostInputRequestTracker } { + function createTracker(clientContext?: IAgentHostClientTelemetryContext, telemetryContext?: IAgentTelemetryContext, getSubagentKind?: (session: string) => AgentSubagentKind | undefined): { telemetry: CapturingTelemetryService; tracker: AgentHostInputRequestTracker } { const telemetry = new CapturingTelemetryService(); return { telemetry, - tracker: new AgentHostInputRequestTracker(new AgentHostTelemetryReporter(telemetry), () => ({ elapsed: () => 25 }), () => clientContext, () => telemetryContext), + tracker: new AgentHostInputRequestTracker(new AgentHostTelemetryReporter(telemetry), () => ({ elapsed: () => 25 }), () => clientContext, () => telemetryContext, getSubagentKind), }; } @@ -222,16 +222,22 @@ suite('AgentHostInputRequestTracker', () => { assert.deepStrictEqual(telemetry.events, []); }); - test('emits subagent identifiers', () => { - const { telemetry, tracker } = createTracker(); + test('emits subagent identifiers and what the subagent chat represents', () => { + const phaseChat = buildSubagentChatUri(rootSession, 'fusion:fusion-1:phase-1'); + const { telemetry, tracker } = createTracker(undefined, undefined, session => session === phaseChat ? 'fusionPhase' : undefined); const request: ChatInputRequest = withChatInputRequestPurpose({ id: 'request-1', questions: [] }, ChatInputRequestPurpose.AskUser); - tracker.inputRequested('mock', subagentChat, 'turn-1', request); - tracker.inputCompleted(subagentChat, accept(request.id), completedState(subagentChat, 'turn-1', request)); + for (const chat of [subagentChat, phaseChat]) { + tracker.inputRequested('mock', chat, 'turn-1', request); + tracker.inputCompleted(chat, accept(request.id), completedState(chat, 'turn-1', request)); + } assert.deepStrictEqual(telemetry.events.map(event => { - const data = event.data as { agentSessionId: string; isSubagentSession: boolean }; - return { agentSessionId: data.agentSessionId, isSubagentSession: data.isSubagentSession }; - }), [{ agentSessionId: 'root-session', isSubagentSession: true }]); + const data = event.data as { agentSessionId: string; isSubagentSession: boolean; subagentKind?: AgentSubagentKind }; + return { agentSessionId: data.agentSessionId, isSubagentSession: data.isSubagentSession, subagentKind: data.subagentKind }; + }), [ + { agentSessionId: 'root-session', isSubagentSession: true, subagentKind: 'task' }, + { agentSessionId: 'root-session', isSubagentSession: true, subagentKind: 'fusionPhase' }, + ]); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index 45a5a2078210..13f19011e990 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -25,7 +25,7 @@ import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelatio import { withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; import { CustomizationType, McpServerStatus, SessionInputRequestKind, type McpServerCustomization } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; -import { buildDefaultChatUri, MessageKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, buildSubagentChatUri, MessageKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentHostChatContributions } from '../../common/agentHostChatContributionsService.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; @@ -393,6 +393,30 @@ suite('AgentSideEffects — tool call telemetry', () => { }]); }); + test('classifies tool calls in subagent chats by subagent kind and reports the phase model', () => { + setupSession(); + agent.setModels([{ provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }]); + startTurn('turn-1'); + const runSubagentTool = (toolCallId: string, phase?: { subagentKind: 'fusionPhase'; model: string }) => { + const chatUri = buildSubagentChatUri(sessionUri, toolCallId); + stateManager.addChat(sessionKey, chatUri); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId, agentName: 'agent', agentDisplayName: 'Agent', ...phase }); + const turnId = stateManager.getActiveTurnId(chatUri) ?? 'missing-subagent-turn'; + const fireOnChat = (action: ChatAction) => agent.fireProgress({ kind: 'action', resource: URI.parse(chatUri), action }); + fireOnChat({ type: ActionType.ChatToolCallStart, turnId, toolCallId: `${toolCallId}-view`, toolName: 'view', displayName: 'view' }); + fireOnChat({ type: ActionType.ChatToolCallComplete, turnId, toolCallId: `${toolCallId}-view`, result: { success: true, pastTenseMessage: 'viewed' } }); + agent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(defaultChatUri), toolCallId }); + }; + runSubagentTool('call-task'); + runSubagentTool('fusion:fusion-1:phase-1', { subagentKind: 'fusionPhase', model: 'gpt-5.5' }); + completeTurn('turn-1'); + + assert.deepStrictEqual(agentHostToolEvents().map(({ data }) => ({ toolCallId: data.toolCallId, isSubagentSession: data.isSubagentSession, subagentKind: data.subagentKind, model: data.model })), [ + { toolCallId: 'call-task-view', isSubagentSession: true, subagentKind: 'task', model: undefined }, + { toolCallId: 'fusion:fusion-1:phase-1-view', isSubagentSession: true, subagentKind: 'fusionPhase', model: { trusted: true, value: 'gpt-5.5' } }, + ]); + }); + test('attributes tool telemetry to the initiating turn client', () => { setupSession(); const clientContext: IAgentHostClientTelemetryContext = { diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 24c6ed8499c2..ce455cadb45c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -1259,13 +1259,13 @@ suite('AgentSideEffects — turn tracker telemetry', () => { assert.deepStrictEqual({ completed: completedEvents().map(event => { const data = event.data as Record; - return { isSubagentSession: data.isSubagentSession, interactionMode: data.interactionMode, modelCallCount: data.modelCallCount, subagentTaskModelSource: data.subagentTaskModelSource }; + return { isSubagentSession: data.isSubagentSession, subagentKind: data.subagentKind, interactionMode: data.interactionMode, modelCallCount: data.modelCallCount, subagentTaskModelSource: data.subagentTaskModelSource }; }), correlations: agent.modelCallTurnCorrelationCalls.map(({ chat, ...correlation }) => ({ chat: chat.toString(), ...correlation })), }, { completed: [ - { isSubagentSession: true, interactionMode: 'plan', modelCallCount: 1, subagentTaskModelSource: 'task_argument' }, - { isSubagentSession: false, interactionMode: 'plan', modelCallCount: 0, subagentTaskModelSource: undefined }, + { isSubagentSession: true, subagentKind: 'task', interactionMode: 'plan', modelCallCount: 1, subagentTaskModelSource: 'task_argument' }, + { isSubagentSession: false, subagentKind: undefined, interactionMode: 'plan', modelCallCount: 0, subagentTaskModelSource: undefined }, ], correlations: [{ chat: defaultChatUri, @@ -1275,6 +1275,73 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }); }); + test('classifies HydraFusion phase chats separately from task subagents and reports the phase model', () => { + setupSession(); + agent.setModels([ + { provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }, + { provider: 'mock', id: 'gpt-5.5-mini', name: 'GPT 5.5 Mini', supportsVision: false }, + ]); + startTurn('turn-parent'); + const phaseToolCallId = 'fusion:fusion-1:phase-1'; + const phaseChatUri = buildSubagentChatUri(sessionUri, phaseToolCallId); + stateManager.addChat(sessionKey, phaseChatUri); + const runPhaseTurn = (model: string) => { + agent.fireProgress({ + kind: 'subagent_started', + chat: URI.parse(defaultChatUri), + toolCallId: phaseToolCallId, + agentName: 'hydrafusion-phase', + agentDisplayName: 'Main pass', + subagentKind: 'fusionPhase', + model, + }); + const turnId = stateManager.getActiveTurnId(phaseChatUri); + agent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(defaultChatUri), toolCallId: phaseToolCallId }); + return turnId; + }; + const phaseTurnId = runPhaseTurn('gpt-5.5'); + const resumedPhaseTurnId = runPhaseTurn('gpt-5.5-mini'); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-parent', duration: 1000 }); + + assert.deepStrictEqual({ + phaseTurnsStarted: phaseTurnId !== undefined && resumedPhaseTurnId !== undefined && phaseTurnId !== resumedPhaseTurnId, + completed: completedEvents().map(event => { + const data = event.data as Record; + return { turnId: data.turnId, isSubagentSession: data.isSubagentSession, subagentKind: data.subagentKind, parentToolCallId: data.parentToolCallId, model: capturedModel(data).value }; + }), + }, { + phaseTurnsStarted: true, + completed: [ + { turnId: phaseTurnId, isSubagentSession: true, subagentKind: 'fusionPhase', parentToolCallId: phaseToolCallId, model: 'gpt-5.5' }, + { turnId: resumedPhaseTurnId, isSubagentSession: true, subagentKind: 'fusionPhase', parentToolCallId: phaseToolCallId, model: 'gpt-5.5-mini' }, + { turnId: 'turn-parent', isSubagentSession: false, subagentKind: undefined, parentToolCallId: undefined, model: undefined }, + ], + }); + }); + + test('reports the root turn as the parent of a subagent launched inside a HydraFusion phase', () => { + setupSession(); + startTurn('turn-parent'); + const phaseToolCallId = 'fusion:fusion-1:phase-1'; + stateManager.addChat(sessionKey, buildSubagentChatUri(sessionUri, phaseToolCallId)); + stateManager.addChat(sessionKey, buildSubagentChatUri(sessionUri, 'call-task')); + const chat = URI.parse(defaultChatUri); + agent.fireProgress({ kind: 'subagent_started', chat, toolCallId: phaseToolCallId, agentName: 'hydrafusion-phase', agentDisplayName: 'Main pass', subagentKind: 'fusionPhase' }); + agent.fireProgress({ kind: 'subagent_started', chat, toolCallId: 'call-task', agentName: 'explore', agentDisplayName: 'Explore', parentToolCallId: phaseToolCallId }); + agent.fireProgress({ kind: 'subagent_completed', chat, toolCallId: 'call-task' }); + agent.fireProgress({ kind: 'subagent_completed', chat, toolCallId: phaseToolCallId }); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-parent', duration: 1000 }); + + assert.deepStrictEqual(completedEvents().map(event => { + const data = event.data as Record; + return { parentToolCallId: data.parentToolCallId, subagentKind: data.subagentKind, parentTurnId: data.parentTurnId }; + }), [ + { parentToolCallId: 'call-task', subagentKind: 'task', parentTurnId: 'turn-parent' }, + { parentToolCallId: phaseToolCallId, subagentKind: 'fusionPhase', parentTurnId: 'turn-parent' }, + { parentToolCallId: undefined, subagentKind: undefined, parentTurnId: undefined }, + ]); + }); + test('attributes subagent model-call attempt durations only to the subagent turn', () => { setupSession(); startTurn('turn-parent-finished'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 677e0ecd1041..7dbc98dd925e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -12446,6 +12446,22 @@ Use the attached image as context. }]); }); + test('marks todo store telemetry from inside a Fusion phase as phase activity', async () => { + const telemetryService = new CapturingTelemetryService(); + const { session, mockSession, waitForSignal } = await createAgentSession(disposables, { telemetryService }); + session.resetTurnState('fusion-turn'); + const fusion = { fusionId: 'fusion-1', phaseId: 'phase-1', syntheticModel: 'hydrafusion', policy: 'max', pattern: 'cascade', commitId: 'commit-1' }; + mockSession.fire('assistant.fusion_phase_started', fusionTestData.started); + mockSession.fire('tool.execution_start', { toolCallId: 'tc-sql', toolName: 'sql', arguments: { query: 'SELECT * FROM todos' }, fusion }); + mockSession.fire('tool.execution_complete', { toolCallId: 'tc-sql', success: true, fusion }); + await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete) && signal.parentToolCallId !== undefined); + + assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'todoStoreOperation').map(event => { + const data = event.data as Record; + return { isSubagentSession: data.isSubagentSession, subagentKind: data.subagentKind }; + }), [{ isSubagentSession: true, subagentKind: 'fusionPhase' }]); + }); + test('does not emit todo store telemetry for failed or contributed SQL', async () => { const telemetryService = new CapturingTelemetryService(); const { mockSession } = await createAgentSession(disposables, { @@ -14579,6 +14595,18 @@ Use the attached image as context. }); }); + test('a Fusion phase chat is announced as a phase chat running the model last reported for the phase', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('fusion-turn'); + const fusion = { fusionId: 'fusion-1', phaseId: 'phase-1', syntheticModel: 'hydrafusion', policy: 'max', pattern: 'cascade', commitId: 'commit-1' }; + mockSession.fire('assistant.fusion_phase_started', fusionTestData.started); + mockSession.fire('assistant.fusion_phase_started', { ...fusionTestData.started, model: 'actual-model' }); + mockSession.fire('tool.execution_start', { toolCallId: 'tc-bash', toolName: 'bash', arguments: { command: 'ls' }, fusion }); + assert.deepStrictEqual(signals.flatMap(signal => signal.kind === 'subagent_started' ? [{ toolCallId: signal.toolCallId, subagentKind: signal.subagentKind, model: signal.model }] : []), [ + { toolCallId: 'fusion:fusion-1:phase-1', subagentKind: 'fusionPhase', model: 'actual-model' }, + ]); + }); + test('Fusion tools stay at the root when their phase has no child chat', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); session.resetTurnState('fusion-turn'); From 4a86ed331f5433b773e307408ad05cee9fa12985 Mon Sep 17 00:00:00 2001 From: Dileep Yavanmandha <52841896+dileepyavan@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:10:34 -0700 Subject: [PATCH 10/49] Apply Copilot sandbox network restrictions to integrated browser tools (#339563) Forward resolved session restrictions to Chromium and Playwright, enforce isolated agent pages, and move domain settings under sandbox.network with configuration migrations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/policies/policyData.jsonc | 74 +++++++++--------- .../node/copilotcliSessionService.ts | 4 +- .../singlefolder-tests/browser.tools.test.ts | 9 ++- .../chat.runInTerminal.test.ts | 4 +- .../common/meta/vscode/agentToolCallMeta.ts | 8 ++ .../node/copilot/copilotAgentSession.ts | 7 ++ .../node/copilot/copilotSandboxPolicy.ts | 20 ++++- .../test/common/agentMetaReaders.test.ts | 11 +++ .../test/node/sessionSandbox.test.ts | 37 ++++++++- .../browserView/common/browserView.ts | 4 + .../browserView/common/browserViewGroup.ts | 12 ++- .../browserView/common/playwrightService.ts | 2 + .../electron-main/browserSession.ts | 23 +++++- .../browserView/electron-main/browserView.ts | 1 + .../electron-main/browserViewGroup.ts | 16 +++- .../browserViewGroupMainService.ts | 10 ++- .../electron-main/browserViewMainService.ts | 17 +++++ .../node/browserViewGroupRemoteService.ts | 6 ++ .../browserView/node/playwrightService.ts | 44 +++++++++-- .../test/common/browserViewGroup.test.ts | 13 +++- .../networkFilter/common/domainMatcher.ts | 2 +- .../common/networkFilterService.ts | 36 +++++++-- .../platform/networkFilter/common/settings.ts | 4 +- .../test/common/networkFilterService.test.ts | 64 ++++++++++++++++ .../common/sandboxSettingsResolutionHelper.ts | 29 ++++++- .../sandboxSettingsResolutionHelper.test.ts | 12 +++ .../contrib/browserView/common/browserView.ts | 7 ++ .../browserViewWorkbenchService.ts | 7 +- .../tools/browserToolHelpers.ts | 6 +- .../tools/browserTools.contribution.ts | 58 ++++++++++---- .../tools/listBrowserPagesTool.ts | 3 +- .../electron-browser/tools/openBrowserTool.ts | 29 ++++++- .../tools/screenshotBrowserTool.ts | 14 +++- .../tools/openBrowserTool.test.ts | 26 ++++++- .../tools/screenshotBrowserTool.test.ts | 54 +++++++++++++ .../agentHost/agentHostSessionHandler.ts | 5 +- .../chat/browser/chat.shared.contribution.ts | 43 +---------- .../chat/browser/chatNetworkConfiguration.ts | 73 ++++++++++++++++++ .../tools/languageModelToolsService.ts | 1 + .../common/tools/languageModelToolsService.ts | 3 + .../browser/chatNetworkConfiguration.test.ts | 55 ++++++++++++++ .../test/browser/settingsTreeModels.test.ts | 75 +++++++++++++++++++ .../browser/tools/runInTerminalTool.ts | 8 +- .../terminalChatAgentToolsConfiguration.ts | 2 +- .../runInTerminalTool.test.ts | 4 +- 45 files changed, 795 insertions(+), 147 deletions(-) create mode 100644 src/vs/workbench/contrib/browserView/test/electron-browser/tools/screenshotBrowserTool.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatNetworkConfiguration.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatNetworkConfiguration.test.ts diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index be0ac4bbe573..9573b7bd9bc8 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -56,42 +56,6 @@ "status": "enforced" } }, - { - "key": "chat.agent.allowedNetworkDomains", - "name": "ChatAgentAllowedNetworkDomains", - "category": "InteractiveSession", - "minimumVersion": "1.116", - "localization": { - "description": { - "key": "chat.agent.allowedNetworkDomains", - "value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.network.allowNetwork#` is enabled, all domains are allowed. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence.\n\nChanges may not take full effect until VS Code is restarted." - } - }, - "type": "array", - "default": [], - "included": true, - "agentHost": { - "status": "partial" - } - }, - { - "key": "chat.agent.deniedNetworkDomains", - "name": "ChatAgentDeniedNetworkDomains", - "category": "InteractiveSession", - "minimumVersion": "1.116", - "localization": { - "description": { - "key": "chat.agent.deniedNetworkDomains", - "value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. This does not apply when `#chat.agent.sandbox.network.allowNetwork#` is enabled. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`.\n\nChanges may not take full effect until VS Code is restarted." - } - }, - "type": "array", - "default": [], - "included": true, - "agentHost": { - "status": "partial" - } - }, { "key": "chat.agent.enabled", "name": "ChatAgentMode", @@ -118,7 +82,7 @@ "localization": { "description": { "key": "chat.agent.networkFilter", - "value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.allowedNetworkDomains#` and `#chat.agent.deniedNetworkDomains#`. Domain filtering is also applied to those tools when `#chat.agent.sandbox.enabled#` is enabled.\n\nChanges may not take full effect until VS Code is restarted." + "value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.sandbox.network.allowedDomains#` and `#chat.agent.sandbox.network.deniedDomains#`. Domain filtering is also applied to those tools when `#chat.agent.sandbox.enabled#` is enabled.\n\nChanges may not take full effect until VS Code is restarted." } }, "type": "boolean", @@ -178,6 +142,24 @@ "status": "notApplicable" } }, + { + "key": "chat.agent.sandbox.network.allowedDomains", + "name": "ChatAgentAllowedNetworkDomains", + "category": "InteractiveSession", + "minimumVersion": "1.116", + "localization": { + "description": { + "key": "chat.agent.allowedNetworkDomains", + "value": "Allowed domains for network access by agent tools (fetch tool, integrated browser) when `#chat.agent.networkFilter#` is enabled. In Copilot Agent Host sessions with sandboxing (`#chat.agent.sandbox.enabled#`) enabled, also restricts the integrated browser when outbound network access (`#chat.agent.sandbox.network.allowNetwork#`) is allowed; an empty allow list adds no sandbox restriction. This list does not enable outbound access. Supports wildcards like `*.example.com`. Outside sandboxing, when both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.sandbox.network.deniedDomains#`) take precedence.\n\nChanges may not take full effect until VS Code is restarted." + } + }, + "type": "array", + "default": [], + "included": true, + "agentHost": { + "status": "partial" + } + }, { "key": "chat.agent.sandbox.network.allowNetwork", "name": "ChatAgentSandboxAllowNetwork", @@ -196,6 +178,24 @@ "status": "notApplicable" } }, + { + "key": "chat.agent.sandbox.network.deniedDomains", + "name": "ChatAgentDeniedNetworkDomains", + "category": "InteractiveSession", + "minimumVersion": "1.116", + "localization": { + "description": { + "key": "chat.agent.deniedNetworkDomains", + "value": "Denied domains for network access by agent tools (fetch tool, integrated browser) when `#chat.agent.networkFilter#` is enabled. Also applies to the integrated browser in sandboxed Copilot Agent Host sessions (`#chat.agent.sandbox.enabled#`); allowing outbound network access (`#chat.agent.sandbox.network.allowNetwork#`) does not bypass this list. Takes precedence over `#chat.agent.sandbox.network.allowedDomains#`. Supports wildcards like `*.example.com`.\n\nChanges may not take full effect until VS Code is restarted." + } + }, + "type": "array", + "default": [], + "included": true, + "agentHost": { + "status": "partial" + } + }, { "key": "chat.agentHost.claudeAgent.enabled", "name": "Claude3PIntegration", diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index d12a82dea983..b55cb62887b2 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -826,8 +826,8 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS const fileSystemSetting = rawFileSystemSetting && typeof rawFileSystemSetting === 'object' ? rawFileSystemSetting as IAgentSandboxFileSystemSettings : undefined; - const allowedHosts = readStringArraySetting(this.configurationService, 'chat.agent.allowedNetworkDomains'); - const blockedHosts = readStringArraySetting(this.configurationService, 'chat.agent.deniedNetworkDomains'); + const allowedHosts = readStringArraySetting(this.configurationService, 'chat.agent.sandbox.network.allowedDomains'); + const blockedHosts = readStringArraySetting(this.configurationService, 'chat.agent.sandbox.network.deniedDomains'); return buildSandboxConfigForCLI(process.platform, sandboxSetting, fileSystemSetting, { allowedHosts, blockedHosts }); } diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts index ebf900ce78bb..f311a5f501cb 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts @@ -168,10 +168,11 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { }); const browserConfig = vscode.workspace.getConfiguration('workbench.browser'); const agentConfig = vscode.workspace.getConfiguration('chat.agent'); + const agentNetworkConfig = vscode.workspace.getConfiguration('chat.agent.sandbox.network'); try { - await agentConfig.update('allowedNetworkDomains', ['*'], vscode.ConfigurationTarget.Global); - await agentConfig.update('deniedNetworkDomains', ['localhost'], vscode.ConfigurationTarget.Global); + await agentNetworkConfig.update('allowedDomains', ['*'], vscode.ConfigurationTarget.Global); + await agentNetworkConfig.update('deniedDomains', ['localhost'], vscode.ConfigurationTarget.Global); await agentConfig.update('networkFilter', true, vscode.ConfigurationTarget.Global); await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global); @@ -233,8 +234,8 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { } finally { await browserConfig.update('dataStorage', undefined, vscode.ConfigurationTarget.Global); await agentConfig.update('networkFilter', undefined, vscode.ConfigurationTarget.Global); - await agentConfig.update('allowedNetworkDomains', undefined, vscode.ConfigurationTarget.Global); - await agentConfig.update('deniedNetworkDomains', undefined, vscode.ConfigurationTarget.Global); + await agentNetworkConfig.update('allowedDomains', undefined, vscode.ConfigurationTarget.Global); + await agentNetworkConfig.update('deniedDomains', undefined, vscode.ConfigurationTarget.Global); await Promise.all(vscode.window.browserTabs.map(tab => tab.close())); await new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts index e79388f20d7b..33907ee18821 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -319,7 +319,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { this.timeout(60000); const configuration = vscode.workspace.getConfiguration(); - await configuration.update('chat.agent.allowedNetworkDomains', ['example.com'], vscode.ConfigurationTarget.Global); + await configuration.update('chat.agent.sandbox.network.allowedDomains', ['example.com'], vscode.ConfigurationTarget.Global); try { const output = await invokeRunInTerminal('curl -s --max-time 5 https://example.com'); const trimmed = output.trim(); @@ -329,7 +329,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { ]; assert.ok(acceptable.some(value => trimmed.includes(value) || trimmed === value), `Unexpected output: ${JSON.stringify(trimmed)}`); } finally { - await configuration.update('chat.agent.allowedNetworkDomains', undefined, vscode.ConfigurationTarget.Global); + await configuration.update('chat.agent.sandbox.network.allowedDomains', undefined, vscode.ConfigurationTarget.Global); } }); diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentToolCallMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentToolCallMeta.ts index d3f8741c28a0..096b44ea3598 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/agentToolCallMeta.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/agentToolCallMeta.ts @@ -5,6 +5,7 @@ import type { Mutable } from '../../../../../base/common/types.js'; import { hasAgentMetadata } from '../metadata.js'; +import { ISandboxNetworkRestrictions, isSandboxNetworkRestrictions } from '../../../../sandbox/common/sandboxSettingsResolutionHelper.js'; /** Anything carrying a tool call's `_meta` bag (persisted state or wire actions). */ interface IHasToolCallMeta { @@ -18,6 +19,8 @@ interface IHasToolCallMeta { * wrong-typed values. */ export interface IToolCallMeta { + /** Trusted Copilot host snapshot for integrated-browser client tools. Absent for other tools and harnesses. */ + readonly 'vscode.copilotSandboxNetworkRestrictions'?: ISandboxNetworkRestrictions; readonly 'agentHost.sandboxBypass'?: boolean; /** * VS Code rendering hint. `terminal` routes the call to the command/output @@ -62,6 +65,7 @@ export type AgentFusionPhaseStatus = typeof fusionPhaseStatuses[number]; const knownFusionPhaseStatuses: ReadonlySet = new Set(fusionPhaseStatuses); const toolCallMetaKeys = [ + 'vscode.copilotSandboxNetworkRestrictions', 'agentHost.sandboxBypass', 'toolKind', 'language', 'subagentDescription', 'subagentAgentName', 'subagentChatUri', 'mcpServerName', 'mcpToolName', 'autoApproveBySetting', 'autoApproveRuleResolvable', 'toolSearchCandidates', 'progressMessage', 'fusionPhase', ] as const; @@ -167,6 +171,10 @@ export function readToolCallMeta(source: IHasToolCallMeta): IToolCallMeta { return {}; } const result: Mutable = {}; + const networkRestrictions = meta['vscode.copilotSandboxNetworkRestrictions']; + if (isSandboxNetworkRestrictions(networkRestrictions)) { + result['vscode.copilotSandboxNetworkRestrictions'] = networkRestrictions; + } if (typeof meta['agentHost.sandboxBypass'] === 'boolean') { result['agentHost.sandboxBypass'] = meta['agentHost.sandboxBypass']; } if (isToolKind(meta.toolKind)) { result.toolKind = meta.toolKind; } if (typeof meta.language === 'string') { result.language = meta.language; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index de6888868ab5..88fe924af5f5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -44,6 +44,7 @@ import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../comm import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; import { readCopilotShellAttachment, toCopilotBackgroundShellMeta } from '../../common/meta/copilotBackgroundWorkMeta.js'; import { getSessionSandboxConfig } from '../sessionSandbox.js'; +import { getCopilotBrowserSandboxNetworkRestrictions } from './copilotSandboxPolicy.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostMcpToolRoutingEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder } from '../../common/agentHostTelemetry.js'; import { AgentSession, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, subagentChatTitle, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentTelemetryContext, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; @@ -2131,7 +2132,13 @@ export class CopilotAgentSession extends Disposable { private _createToolCallMeta(toolName: string, parameters: Record | undefined): Mutable { const toolKind = getToolKind(toolName, parameters); const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; + const clientToolName = this._clientToolName(toolName); + const networkRestrictions = this._clientToolNames.has(clientToolName) + ? getCopilotBrowserSandboxNetworkRestrictions(this._configurationService, this._ownerSessionUri.toString(), clientToolName) : undefined; return { + ...(networkRestrictions ? { + 'vscode.copilotSandboxNetworkRestrictions': networkRestrictions, + } : {}), toolKind, language: toolKind === 'terminal' ? getShellLanguage(toolName) : undefined, subagentDescription: subagentMeta?.description, diff --git a/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts b/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts index 0d04a0dbc33c..1a89bf5c573a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts @@ -4,8 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import type { SessionEvent } from '@github/copilot-sdk'; +import { browserChatToolReferenceNames } from '../../../browserView/common/browserChatToolReferenceNames.js'; import type { ILogService } from '../../../log/common/log.js'; -import type { ISessionSandboxPolicy } from '../sessionSandbox.js'; +import { ISandboxNetworkRestrictions, SandboxSettingsResolutionHelper } from '../../../sandbox/common/sandboxSettingsResolutionHelper.js'; +import type { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { getSessionSandboxConfig, type ISessionSandboxPolicy } from '../sessionSandbox.js'; + +/** Resolves session network restrictions only for Copilot's integrated-browser client tools. */ +export function getCopilotBrowserSandboxNetworkRestrictions(configuration: IAgentConfigurationService, session: string, clientToolName: string): ISandboxNetworkRestrictions | undefined { + if (clientToolName !== 'list_browser_pages' && !browserChatToolReferenceNames.some(name => name === clientToolName)) { + return undefined; + } + const sandbox = getSessionSandboxConfig(configuration, session); + const network = SandboxSettingsResolutionHelper.getNetworkRestrictions(sandbox.enabled, sandbox.allowNetwork); + return { + sandboxEnabled: network.sandboxEnabled, + allowNetwork: network.allowNetwork, + allowedDomains: sandbox.allowedNetworkDomains ?? [], + deniedDomains: sandbox.deniedNetworkDomains ?? [], + }; +} /** Projects only resolved boolean sandbox fields; composition and validation remain runtime-owned. */ export function projectCopilotSandboxPolicy(data: Extract['data'], sessionId: string, logService: ILogService): ISessionSandboxPolicy { diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index 22ff94a89039..e69da2ead225 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -40,6 +40,17 @@ suite('Agent host _meta readers', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('Copilot network restrictions round trip and reject malformed or absent metadata', () => { + const restrictions = { sandboxEnabled: true, allowNetwork: false, allowedDomains: ['example.com'], deniedDomains: [] }; + const key = 'vscode.copilotSandboxNetworkRestrictions'; + assert.deepStrictEqual([ + readToolCallMeta(toolCall(toToolCallMeta({ [key]: restrictions })))[key], + readToolCallMeta(toolCall(undefined))[key], + readToolCallMeta(toolCall({ [key]: { ...restrictions, sandboxEnabled: 'true' } }))[key], + readToolCallMeta(toolCall({ [key]: { ...restrictions, deniedDomains: [5] } }))[key], + ], [restrictions, undefined, undefined, undefined]); + }); + suite('session sandbox policy', () => { const policies = [ undefined, diff --git a/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts b/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts index 0849b2f050e3..b09a4c9838ce 100644 --- a/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts @@ -16,7 +16,7 @@ import { buildChatUri, buildSubagentSessionUri, MessageKind, SessionStatus, Tool import { ActionType } from '../../common/state/sessionActions.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; -import { projectCopilotSandboxPolicy } from '../../node/copilot/copilotSandboxPolicy.js'; +import { getCopilotBrowserSandboxNetworkRestrictions, projectCopilotSandboxPolicy } from '../../node/copilot/copilotSandboxPolicy.js'; import { buildSandboxConfigForSdk } from '../../node/copilot/sandboxConfigForSdk.js'; import { getSessionSandboxConfig, getSessionSandboxOverrides } from '../../node/sessionSandbox.js'; import { SessionPermissionManager } from '../../node/sessionPermissions.js'; @@ -167,6 +167,41 @@ suite('Session sandbox configuration', () => { ]); }); + test('browser client tool network restrictions use session selection and managed outbound policy', () => { + const { configuration, create } = setupSession(); + const enabled = create('network-enabled', { sandboxEnabled: 'on' }); + const disabled = create('network-disabled', { sandboxEnabled: 'off' }); + const managed = create('network-managed'); + const bypassed = create('network-bypassed', { sandboxEnabled: 'off' }); + const failClosed = create('network-fail-closed'); + configuration.updateRootConfig({ sandbox: { enabled: 'off', allowNetwork: true, allowedNetworkDomains: ['example.com'], deniedNetworkDomains: ['private.example.com'] } }); + configuration.setSessionSandboxPolicy(managed, { enabled: true, allowOutbound: false }); + configuration.setSessionSandboxPolicy(bypassed, { enabled: true, allowBypass: true }); + configuration.setSessionSandboxEnabled(bypassed, false); + configuration.updateSessionConfig(bypassed, { sandboxEnabled: 'off' }); + configuration.setSessionSandboxPolicy(failClosed, { enabled: true, allowBypass: false, failClosed: true }); + assert.deepStrictEqual([enabled, disabled, managed, bypassed, failClosed].map(session => getCopilotBrowserSandboxNetworkRestrictions(configuration, session, 'openBrowserPage')), [ + { sandboxEnabled: true, allowNetwork: true, allowedDomains: ['example.com'], deniedDomains: ['private.example.com'] }, + { sandboxEnabled: false, allowNetwork: true, allowedDomains: ['example.com'], deniedDomains: ['private.example.com'] }, + { sandboxEnabled: true, allowNetwork: false, allowedDomains: ['example.com'], deniedDomains: ['private.example.com'] }, + { sandboxEnabled: false, allowNetwork: true, allowedDomains: ['example.com'], deniedDomains: ['private.example.com'] }, + { sandboxEnabled: true, allowNetwork: true, allowedDomains: ['example.com'], deniedDomains: ['private.example.com'] }, + ]); + }); + + test('Copilot network metadata is limited to integrated-browser client tools', () => { + const { configuration, create } = setupSession(); + const session = create('browser-only', { sandboxEnabled: 'on' }); + const names = ['openBrowserPage', 'readPage', 'screenshotPage', 'navigatePage', 'clickElement', 'typeInPage', + 'hoverElement', 'dragElement', 'handleDialog', 'runPlaywrightCode', 'list_browser_pages', 'fetchWebPage', 'run_in_terminal', 'read_file']; + assert.deepStrictEqual(names.map(name => [name, getCopilotBrowserSandboxNetworkRestrictions(configuration, session, name) !== undefined]), [ + ['openBrowserPage', true], ['readPage', true], ['screenshotPage', true], ['navigatePage', true], + ['clickElement', true], ['typeInPage', true], ['hoverElement', true], ['dragElement', true], + ['handleDialog', true], ['runPlaywrightCode', true], ['list_browser_pages', true], + ['fetchWebPage', false], ['run_in_terminal', false], ['read_file', false], + ]); + }); + test('forwarded sandbox defaults remain overridable without a runtime managed requirement', () => { const { manager, configuration, create } = setupSession(); const owner = create('legacy-default', { sandboxEnabled: 'off' }); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index f9609ed39b7f..78cee5deca2a 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -8,6 +8,7 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { extUriBiasedIgnorePathCase } from '../../../base/common/resources.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; +import { ISandboxNetworkRestrictions } from '../../sandbox/common/sandboxSettingsResolutionHelper.js'; import { ITunnelProxyInfo } from '../../tunnel/common/tunnelProxy.js'; import { IPermissionCategoryState, ISerializedBrowserPermissionsSnapshot, IBrowserDeviceCandidate, BrowserDeviceType, PermissionCategory } from './browserPermissions.js'; import type { IntegratedBrowserOpenSource } from './browserViewTelemetry.js'; @@ -307,6 +308,7 @@ export interface IBrowserViewCreatedEvent { /** Host, ownership, storage, and initial access for a newly created browser view. */ export interface IBrowserViewCreationContext { + readonly sandboxNetworkRestrictions?: ISandboxNetworkRestrictions; readonly host: IBrowserViewHost; readonly owner: IBrowserViewOwner; readonly session: BrowserViewSessionSelector; @@ -339,6 +341,7 @@ export interface IBrowserViewStorageKeys { } export interface IBrowserViewState { + readonly sandboxSessionId?: string; url: string; title: string; canGoBack: boolean; @@ -570,6 +573,7 @@ export function reviveBrowserViewInfo(info: IBrowserViewSerializedInfo, lastScre } export interface IBrowserViewService { + setSessionNetworkRestrictions(sessionId: string, restrictions: ISandboxNetworkRestrictions): Promise; /** Read-only accessibility snapshot that does not grant agent access. */ getAccessibilitySnapshot(id: string, expectedHostWindowId: number): Promise; diff --git a/src/vs/platform/browserView/common/browserViewGroup.ts b/src/vs/platform/browserView/common/browserViewGroup.ts index 5bdfbc1537bb..012cab304cf7 100644 --- a/src/vs/platform/browserView/common/browserViewGroup.ts +++ b/src/vs/platform/browserView/common/browserViewGroup.ts @@ -5,8 +5,9 @@ import { Event } from '../../../base/common/event.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; -import { IBrowserViewAudience, IBrowserViewCreationContext, matchesBrowserViewAudience } from './browserView.js'; +import { IBrowserViewAudience, IBrowserViewCreationContext, IBrowserViewOwner, matchesBrowserViewAudience } from './browserView.js'; import { CDPEvent, CDPRequest, CDPResponse } from './cdp/types.js'; +import { ISandboxNetworkRestrictions } from '../../sandbox/common/sandboxSettingsResolutionHelper.js'; export const ipcBrowserViewGroupChannelName = 'browserViewGroup'; @@ -25,6 +26,8 @@ export interface IBrowserViewGroup extends IDisposable { } export interface IBrowserViewGroupFilter { + /** Require agent ownership and isolated storage for this session, even for explicitly added views. */ + readonly sandboxSessionId?: string; /** Include views granted to this audience. */ readonly audience?: IBrowserViewAudience; /** Include these views regardless of their audiences. */ @@ -37,6 +40,12 @@ export function matchesBrowserViewGroupFilter(browserId: string, audiences: read || (audienceFilter !== undefined && audiences.some(audience => matchesBrowserViewAudience(audienceFilter, audience))); } +/** Shared visibility gate for sandboxed automation and its workbench page list. */ +export function matchesBrowserViewSandboxSession(owner: IBrowserViewOwner | undefined, sandboxSessionId: string | undefined, expectedSessionId: string | undefined): boolean { + return expectedSessionId === undefined || (owner?.type === 'agent' + && owner.sessionId === expectedSessionId && sandboxSessionId === expectedSessionId); +} + /** * Common service for managing browser view groups across processes. * @@ -47,6 +56,7 @@ export function matchesBrowserViewGroupFilter(browserId: string, audiences: read * The main-process implementation is {@link BrowserViewGroupMainService}. */ export interface IBrowserViewGroupService { + setSessionNetworkRestrictions(sessionId: string, restrictions: ISandboxNetworkRestrictions): Promise; // Dynamic events - one per group instance, keyed by group ID. onDynamicDidDestroy(groupId: string): Event; diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index a179fdd4dda5..201f0dc68534 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ISandboxNetworkRestrictions } from '../../sandbox/common/sandboxSettingsResolutionHelper.js'; export const IPlaywrightService = createDecorator('playwrightService'); @@ -33,6 +34,7 @@ export interface IInvokeFunctionResult { * session can interact with. */ export interface IPlaywrightService { + setSessionNetworkRestrictions(sessionId: string, restrictions: ISandboxNetworkRestrictions): Promise; readonly _serviceBrand: undefined; /** Waits for a newly created browser view to become available and returns its initial summary. */ diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index 8be17ee4308e..99a1c1140625 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -20,6 +20,7 @@ import { FileAccess, Schemas } from '../../../base/common/network.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { localize } from '../../../nls.js'; import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; +import { ISandboxNetworkRestrictions } from '../../sandbox/common/sandboxSettingsResolutionHelper.js'; /** * Holds an Electron session along with its storage scope and unique browser @@ -243,6 +244,20 @@ export class BrowserSession { private readonly _remote: BrowserSessionRemote; private readonly _permissions: BrowserSessionPermissions; private _networkFilterEnabled = false; + private _sandboxNetworkRestrictions?: ISandboxNetworkRestrictions; + sandboxSessionId?: string; + + setSandboxNetworkRestrictions(sessionId: string, restrictions: ISandboxNetworkRestrictions): void { + if (this.storageScope !== BrowserViewStorageScope.Agent) { + throw new Error('Sandbox network restrictions require an isolated Agent browser session'); + } + if (this.sandboxSessionId !== undefined && this.sandboxSessionId !== sessionId) { + throw new Error('Browser sandbox restrictions cannot be shared across agent sessions'); + } + this.sandboxSessionId = sessionId; + this._sandboxNetworkRestrictions = restrictions; + this.updateNetworkFilter(true); + } /** * @deprecated Don't use this directly. Create sessions via the static factory methods. @@ -307,13 +322,13 @@ export class BrowserSession { /** * Dynamically apply network filtering to Agent sessions. */ - private updateNetworkFilter(): void { + private updateNetworkFilter(force = false): void { if (this.storageScope !== BrowserViewStorageScope.Agent) { return; } - const enabled = this.agentNetworkFilterService.isEnabled(); - if (this._networkFilterEnabled === enabled) { + const enabled = this.agentNetworkFilterService.isEnabled(this._sandboxNetworkRestrictions); + if (!force && this._networkFilterEnabled === enabled) { return; } this._networkFilterEnabled = enabled; @@ -325,7 +340,7 @@ export class BrowserSession { callback({ cancel: true }); return; } - callback({ cancel: !this.agentNetworkFilterService.isUriAllowed(uri) }); + callback({ cancel: !this.agentNetworkFilterService.isUriAllowed(uri, this._sandboxNetworkRestrictions) }); } : null); } diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 8292d0707257..816bcf718aa3 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -634,6 +634,7 @@ export class BrowserView extends Disposable { lastError: this._lastError, certificateError: this.session.trust.getCertificateError(url), storageScope: this.session.storageScope, + sandboxSessionId: this.session.sandboxSessionId, storageKeys: { ...this.session.history.storageKeys, ...this.session.permissions.storageKeys }, permissions: this.session.permissions.serialize(), browserZoomIndex: this._browserZoomIndex, diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index 75fb879442b9..edecc7817e99 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -8,7 +8,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { BrowserView } from './browserView.js'; import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget, CDPRequest, CDPResponse, CDPEvent } from '../common/cdp/types.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; -import { IBrowserViewGroup, IBrowserViewGroupFilter, matchesBrowserViewGroupFilter } from '../common/browserViewGroup.js'; +import { IBrowserViewGroup, IBrowserViewGroupFilter, matchesBrowserViewGroupFilter, matchesBrowserViewSandboxSession } from '../common/browserViewGroup.js'; import { BrowserViewStorageScope, IBrowserViewCreationContext } from '../common/browserView.js'; import { IBrowserViewMainService } from './browserViewMainService.js'; import { IProductService } from '../../product/common/productService.js'; @@ -108,19 +108,23 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } const store = new DisposableStore(); - store.add(view.onDidChangeAudiences(() => { + const reconcile = () => { if (this._isActive) { void this._reconcileView(view).catch(error => { this.logService.error(`[BrowserViewGroup] Failed to reconcile view ${view.id}`, error); }); } - })); + }; + store.add(view.onDidChangeAudiences(reconcile)); + store.add(view.onDidChangeOwner(reconcile)); store.add(Event.once(view.onDidClose)(() => this.viewAudienceListeners.deleteAndDispose(view.id))); this.viewAudienceListeners.set(view.id, store); } private async _reconcileView(view: BrowserView): Promise { - const matches = matchesBrowserViewGroupFilter(view.id, view.audiences, this.filter); + const isolatedSessionId = this.filter.sandboxSessionId; + const isolated = matchesBrowserViewSandboxSession(view.owner, view.session.sandboxSessionId, isolatedSessionId); + const matches = isolated && matchesBrowserViewGroupFilter(view.id, view.audiences, this.filter); if (matches) { await this.addView(view.id); } else { @@ -159,6 +163,10 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I if (!view) { throw new Error(`Browser view ${viewId} not found`); } + const isolatedSessionId = this.filter.sandboxSessionId; + if (!matchesBrowserViewSandboxSession(view.owner, view.session.sandboxSessionId, isolatedSessionId)) { + throw new Error(`Browser view ${viewId} is not isolated for agent session ${isolatedSessionId}`); + } if (this.filter.audience?.type === 'agent') { this.browserViewMainService.validateAgentAccess(view); } diff --git a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts index 7f4b9ecbed06..cde933869919 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts @@ -11,6 +11,8 @@ import { IBrowserViewGroupFilter, IBrowserViewGroupService } from '../common/bro import { IBrowserViewCreationContext } from '../common/browserView.js'; import { BrowserViewGroup } from './browserViewGroup.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; +import { IBrowserViewMainService } from './browserViewMainService.js'; +import { ISandboxNetworkRestrictions } from '../../sandbox/common/sandboxSettingsResolutionHelper.js'; export const IBrowserViewGroupMainService = createDecorator('browserViewGroupMainService'); @@ -30,7 +32,8 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV private readonly groups = this._register(new DisposableMap()); constructor( - @IInstantiationService private readonly instantiationService: IInstantiationService + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService, ) { super(); } @@ -51,6 +54,11 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV this.groups.deleteAndDispose(id); throw error; } + + } + + setSessionNetworkRestrictions(sessionId: string, restrictions: ISandboxNetworkRestrictions): Promise { + return this.browserViewMainService.setSessionNetworkRestrictions(sessionId, restrictions); } async destroyGroup(groupId: string): Promise { diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 64d717e6f3ff..6071bc729a8c 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -27,6 +27,7 @@ import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; import { formatBrowserViewAccessibility } from './browserViewAccessibility.js'; +import { ISandboxNetworkRestrictions } from '../../sandbox/common/sandboxSettingsResolutionHelper.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -457,7 +458,15 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private _createBrowserView(id: string, options: IBrowserViewCreateOptions, editorOpenRequest?: IBrowserViewEditorOpenOptions, electronOptions?: Electron.WebContentsViewConstructorOptions): BrowserView { const hasAgentAccess = options.owner.type === 'agent' || options.initialAudiences?.some(audience => audience.type === 'agent') === true; + if (options.sandboxNetworkRestrictions && (options.owner.type !== 'agent' + || typeof options.session === 'string' || options.session.scope !== BrowserViewStorageScope.Agent + || options.session.affinity !== options.owner.sessionId)) { + throw new Error('Sandboxed browser views must use their owning agent session storage affinity'); + } const browserSession = this._resolveBrowserSession(id, options.host.windowId, options.session); + if (options.sandboxNetworkRestrictions && options.owner.type === 'agent') { + browserSession.setSandboxNetworkRestrictions(options.owner.sessionId, options.sandboxNetworkRestrictions); + } if (hasAgentAccess) { this.validateAgentStorageScope(browserSession.storageScope); } @@ -543,6 +552,14 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } } + async setSessionNetworkRestrictions(sessionId: string, restrictions: ISandboxNetworkRestrictions): Promise { + for (const [, view] of this.browserViews) { + if (view.owner.type === 'agent' && view.owner.sessionId === sessionId && view.session.sandboxSessionId === sessionId) { + view.session.setSandboxNetworkRestrictions(sessionId, restrictions); + } + } + } + private async openNew( url: string, context: IBrowserViewCreationContext, diff --git a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts index 8711b7970038..cb2101d6581c 100644 --- a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts +++ b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts @@ -10,6 +10,7 @@ import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; import { IBrowserViewGroup, IBrowserViewGroupFilter, IBrowserViewGroupService, ipcBrowserViewGroupChannelName } from '../common/browserViewGroup.js'; import { IBrowserViewCreationContext } from '../common/browserView.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; +import { ISandboxNetworkRestrictions } from '../../sandbox/common/sandboxSettingsResolutionHelper.js'; /** * Remote-process service for managing browser view groups. @@ -21,6 +22,7 @@ import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; * Usable from the shared process. */ export interface IBrowserViewGroupRemoteService { + setSessionNetworkRestrictions(sessionId: string, restrictions: ISandboxNetworkRestrictions): Promise; /** * Create a new browser view group. */ @@ -79,6 +81,10 @@ export class BrowserViewGroupRemoteService implements IBrowserViewGroupRemoteSer return this._wrap(id); } + setSessionNetworkRestrictions(sessionId: string, restrictions: ISandboxNetworkRestrictions): Promise { + return this._groupService.setSessionNetworkRestrictions(sessionId, restrictions); + } + private _wrap(id: string): IBrowserViewGroup { const group = new RemoteBrowserViewGroup(id, this._groupService); this._groups.set(id, group); diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index d3bb90c07c6f..e3d60df31b86 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -7,7 +7,9 @@ import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lif import { DeferredPromise, disposableTimeout, raceTimeout, timeout } from '../../../base/common/async.js'; import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; +import { createSandboxNetworkFilter, IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; +import { ISandboxNetworkRestrictions } from '../../sandbox/common/sandboxSettingsResolutionHelper.js'; +import { equals } from '../../../base/common/objects.js'; import { IInvokeFunctionResult, IPlaywrightService } from '../common/playwrightService.js'; import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; import { IBrowserViewGroup } from '../common/browserViewGroup.js'; @@ -62,6 +64,29 @@ export class PlaywrightService extends Disposable implements IPlaywrightService declare readonly _serviceBrand: undefined; private readonly _sessions = this._register(new DisposableMap()); + private readonly _networkRestrictions = new Map(); + private readonly _networkRestrictionUpdates = new Map>(); + + async setSessionNetworkRestrictions(sessionId: string, restrictions: ISandboxNetworkRestrictions): Promise { + const apply = async () => { + await this._pendingInits.get(sessionId); + await this.browserViewGroupRemoteService.setSessionNetworkRestrictions(sessionId, restrictions); + if (!equals(this._networkRestrictions.get(sessionId), restrictions)) { + await this.disposeSession(sessionId); + this._networkRestrictions.set(sessionId, restrictions); + } + }; + // A failed update is reported to its caller but must not prevent a later retry. + const update = (this._networkRestrictionUpdates.get(sessionId) ?? Promise.resolve()).then(apply, apply); + this._networkRestrictionUpdates.set(sessionId, update); + try { + await update; + } finally { + if (this._networkRestrictionUpdates.get(sessionId) === update) { + this._networkRestrictionUpdates.delete(sessionId); + } + } + } /** In-flight session initializations keyed by session ID. */ private readonly _pendingInits = new Map>(); @@ -86,6 +111,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService * connection if the session does not already exist. */ private async _getOrCreateSession(sessionId: string): Promise { + await this._networkRestrictionUpdates.get(sessionId); const existing = this._sessions.get(sessionId); if (existing) { this._touchSession(sessionId); @@ -113,14 +139,17 @@ export class PlaywrightService extends Disposable implements IPlaywrightService */ private async _initSession(sessionId: string): Promise { this.logService.debug(`[PlaywrightService] Initializing session ${sessionId}`); + const restrictions = this._networkRestrictions.get(sessionId); const group = await this.browserViewGroupRemoteService.createGroup( - { audience: { type: 'agent', sessionId } }, + { audience: { type: 'agent', sessionId }, ...(restrictions?.sandboxEnabled ? { sandboxSessionId: sessionId } : {}) }, { host: { windowId: this.windowId }, - ...getAgentBrowserViewCreationDefaults(sessionId, this.useSessionStorageAffinity ? sessionId : undefined) + ...getAgentBrowserViewCreationDefaults(sessionId, restrictions || this.useSessionStorageAffinity ? sessionId : undefined), + ...(restrictions?.sandboxEnabled ? { initialAudiences: [{ type: 'agent' as const, sessionId }] } : {}), + ...(restrictions ? { sandboxNetworkRestrictions: restrictions } : {}), } ); @@ -179,7 +208,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService group, actionScope, this.logService, - this.agentNetworkFilterService, + restrictions ? createSandboxNetworkFilter(this.agentNetworkFilterService, restrictions) : this.agentNetworkFilterService, this.telemetryService, ); @@ -187,8 +216,10 @@ export class PlaywrightService extends Disposable implements IPlaywrightService // recreated fresh on the next tool call. browser.on('disconnected', () => { this.logService.debug(`[PlaywrightService] Browser disconnected for session ${sessionId}`); - this._sessions.deleteAndDispose(sessionId); - this._inactivityTimers.deleteAndDispose(sessionId); + if (this._sessions.get(sessionId) === session) { + this._sessions.deleteAndDispose(sessionId); + this._inactivityTimers.deleteAndDispose(sessionId); + } }); this._sessions.set(sessionId, session); @@ -237,6 +268,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService // --- Session lifecycle --- async disposeSession(sessionId: string): Promise { + this._networkRestrictions.delete(sessionId); if (this._sessions.has(sessionId)) { this.logService.debug(`[PlaywrightService] Disposing session ${sessionId}`); this._sessions.deleteAndDispose(sessionId); diff --git a/src/vs/platform/browserView/test/common/browserViewGroup.test.ts b/src/vs/platform/browserView/test/common/browserViewGroup.test.ts index cb87d19ef8d8..39da9dba3f58 100644 --- a/src/vs/platform/browserView/test/common/browserViewGroup.test.ts +++ b/src/vs/platform/browserView/test/common/browserViewGroup.test.ts @@ -5,11 +5,22 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { matchesBrowserViewGroupFilter } from '../../common/browserViewGroup.js'; +import { matchesBrowserViewGroupFilter, matchesBrowserViewSandboxSession } from '../../common/browserViewGroup.js'; suite('BrowserViewGroup', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('sandboxed visibility excludes user pages, other agents and non-isolated storage', () => { + assert.deepStrictEqual([ + matchesBrowserViewSandboxSession({ type: 'user' }, undefined, undefined), + matchesBrowserViewSandboxSession({ type: 'user' }, 'session', 'session'), + matchesBrowserViewSandboxSession({ type: 'agent', sessionId: 'other' }, 'session', 'session'), + matchesBrowserViewSandboxSession({ type: 'agent', sessionId: 'session' }, undefined, 'session'), + matchesBrowserViewSandboxSession({ type: 'agent', sessionId: 'session' }, 'other', 'session'), + matchesBrowserViewSandboxSession({ type: 'agent', sessionId: 'session' }, 'session', 'session'), + ], [true, false, false, false, false, true]); + }); + test('matches browser IDs and audiences', () => { const sessionAudience = [{ type: 'agent', sessionId: 'session' }] as const; const allAgentsAudience = [{ type: 'agent' }] as const; diff --git a/src/vs/platform/networkFilter/common/domainMatcher.ts b/src/vs/platform/networkFilter/common/domainMatcher.ts index 46b1a6f3e89e..a8a348f8e017 100644 --- a/src/vs/platform/networkFilter/common/domainMatcher.ts +++ b/src/vs/platform/networkFilter/common/domainMatcher.ts @@ -254,7 +254,7 @@ export function extractDomainFromUri(uri: URI): string | undefined { * @param deniedPatterns Array of denied domain patterns. * @returns `true` if the domain is allowed, `false` if it is blocked. */ -export function isDomainAllowed(domain: string, allowedPatterns: string[], deniedPatterns: string[]): boolean { +export function isDomainAllowed(domain: string, allowedPatterns: readonly string[], deniedPatterns: readonly string[]): boolean { // Restrictive default: deny all when both lists are empty. if (allowedPatterns.length === 0 && deniedPatterns.length === 0) { return false; diff --git a/src/vs/platform/networkFilter/common/networkFilterService.ts b/src/vs/platform/networkFilter/common/networkFilterService.ts index 46e6dfe53340..90cd8cd4994e 100644 --- a/src/vs/platform/networkFilter/common/networkFilterService.ts +++ b/src/vs/platform/networkFilter/common/networkFilterService.ts @@ -11,6 +11,7 @@ import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ISandboxNetworkRestrictions } from '../../sandbox/common/sandboxSettingsResolutionHelper.js'; import { extractDomainFromUri, isDomainAllowed } from './domainMatcher.js'; import { AgentNetworkDomainSettingId } from './settings.js'; @@ -28,8 +29,8 @@ function isFilteredNetworkScheme(uri: URI): boolean { * integrated browser) based on the configured allowed/denied domain lists. * * Filtering is active for all callers when the `chat.agent.networkFilter` setting - * is enabled. - * When both domain lists are empty, all domains are denied. + * is enabled. Copilot browser calls can additionally supply resolved sandbox restrictions. + * When both domain lists are empty, all domains are denied outside sandboxing. * When a domain appears on the denied list it is always blocked, even if it * also matches an entry on the allowed list. */ @@ -42,12 +43,12 @@ export interface IAgentNetworkFilterService { * File URIs and unfiltered schemes without an authority always pass. * @returns `true` if the URI's domain is allowed, `false` if blocked. */ - isUriAllowed(uri: URI): boolean; + isUriAllowed(uri: URI, restrictions?: ISandboxNetworkRestrictions): boolean; /** * Returns whether network filtering is currently enabled. */ - isEnabled(): boolean; + isEnabled(restrictions?: ISandboxNetworkRestrictions): boolean; /** * Formats an error message for a blocked URI based on the current filter configuration. @@ -100,9 +101,9 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo this.domainCache.clear(); } - isUriAllowed(uri: URI): boolean { + isUriAllowed(uri: URI, restrictions?: ISandboxNetworkRestrictions): boolean { // When domain filtering is inactive, allow all requests. - if (!this.isEnabled()) { + if (!this.isEnabled(restrictions)) { return true; } @@ -112,11 +113,20 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo return true; } + if (restrictions?.sandboxEnabled && !restrictions.allowNetwork) { + return false; + } + const domain = extractDomainFromUri(uri); if (!domain) { return !isFilteredNetworkScheme(uri); } + if (restrictions) { + const allowed = restrictions.sandboxEnabled && restrictions.allowedDomains.length === 0 ? ['*'] : restrictions.allowedDomains; + return isDomainAllowed(domain, allowed, restrictions.deniedDomains); + } + let result = this.domainCache.get(domain); if (result === undefined) { result = isDomainAllowed(domain, this.allowedPatterns, this.deniedPatterns); @@ -126,8 +136,8 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo return result; } - isEnabled(): boolean { - return this.networkFilterEnabled; + isEnabled(restrictions?: ISandboxNetworkRestrictions): boolean { + return this.networkFilterEnabled || restrictions?.sandboxEnabled === true; } formatError(uri: URI): string { @@ -141,3 +151,13 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo ); } } + +export function createSandboxNetworkFilter(service: IAgentNetworkFilterService, restrictions: ISandboxNetworkRestrictions): IAgentNetworkFilterService { + return { + _serviceBrand: undefined, + onDidChange: service.onDidChange, + isUriAllowed: uri => service.isUriAllowed(uri, restrictions), + isEnabled: () => service.isEnabled(restrictions), + formatError: uri => service.formatError(uri), + }; +} diff --git a/src/vs/platform/networkFilter/common/settings.ts b/src/vs/platform/networkFilter/common/settings.ts index 3ece1e672813..af14947550d8 100644 --- a/src/vs/platform/networkFilter/common/settings.ts +++ b/src/vs/platform/networkFilter/common/settings.ts @@ -8,6 +8,6 @@ */ export const enum AgentNetworkDomainSettingId { NetworkFilter = 'chat.agent.networkFilter', - AllowedNetworkDomains = 'chat.agent.allowedNetworkDomains', - DeniedNetworkDomains = 'chat.agent.deniedNetworkDomains', + AllowedNetworkDomains = 'chat.agent.sandbox.network.allowedDomains', + DeniedNetworkDomains = 'chat.agent.sandbox.network.deniedDomains', } diff --git a/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts b/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts index d63a28abb811..77e28f3c031a 100644 --- a/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts +++ b/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts @@ -9,6 +9,8 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ConfigurationTarget } from '../../../configuration/common/configuration.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { ISandboxNetworkRestrictions } from '../../../sandbox/common/sandboxSettingsResolutionHelper.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../sandbox/common/settings.js'; import { AgentNetworkFilterService } from '../../common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../common/settings.js'; @@ -58,12 +60,74 @@ suite('AgentNetworkFilterService', () => { assert.strictEqual(service.isUriAllowed(URI.parse('https://blocked.com')), true); }); + test('explicit browser sandbox restrictions reuse the domain filter without enabling networkFilter', async () => { + configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); + const service = await createService(); + const restrictions: ISandboxNetworkRestrictions = { + sandboxEnabled: true, allowNetwork: true, allowedDomains: ['*.example.com'], deniedDomains: ['private.example.com'], + }; + assert.deepStrictEqual({ + enabled: service.isEnabled(restrictions), + allowed: ['https://example.com', 'https://docs.example.com', 'https://private.example.com', 'https://other.com'].map(url => service.isUriAllowed(URI.parse(url), restrictions)), + }, { enabled: true, allowed: [true, true, false, false] }); + }); + + test('explicit browser sandbox outbound access without an allow list permits non-denied hosts', async () => { + configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); + const service = await createService(); + const restrictions: ISandboxNetworkRestrictions = { sandboxEnabled: true, allowNetwork: true, allowedDomains: [], deniedDomains: [] }; + const withoutLists = service.isUriAllowed(URI.parse('https://example.com'), restrictions); + assert.deepStrictEqual({ + withoutLists, + withDeniedList: ['https://example.com', 'https://evil.com'].map(url => service.isUriAllowed(URI.parse(url), { ...restrictions, deniedDomains: ['evil.com'] })), + }, { withoutLists: true, withDeniedList: [true, false] }); + }); + + test('Copilot session restrictions do not affect other harnesses or sessions', async () => { + configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); + configService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.Off); + configService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxAllowNetwork, true); + configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); + const service = await createService(); + const restrictions: ISandboxNetworkRestrictions = { + sandboxEnabled: true, allowNetwork: true, allowedDomains: ['example.com'], deniedDomains: [], + }; + const snapshot = (values?: ISandboxNetworkRestrictions) => ({ + enabled: service.isEnabled(values), + allowed: ['https://example.com', 'https://other.com'].map(url => service.isUriAllowed(URI.parse(url), values)), + }); + const states = [snapshot(), snapshot(restrictions), snapshot({ ...restrictions, allowNetwork: false }), snapshot()]; + assert.deepStrictEqual(states, [ + { enabled: false, allowed: [true, true] }, + { enabled: true, allowed: [true, false] }, + { enabled: true, allowed: [false, false] }, + { enabled: false, allowed: [true, true] }, + ]); + }); + test('denies all domains when both lists are empty', async () => { const service = await createService(); assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), false); assert.strictEqual(service.isUriAllowed(URI.parse('https://anything.test')), false); }); + test('only explicit browser restrictions enable sandbox filtering, without caching across calls', async () => { + configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); + configService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); + configService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxAllowNetwork, false); + const service = await createService(); + const uri = URI.parse('https://example.com'); + const restrictions: ISandboxNetworkRestrictions = { sandboxEnabled: true, allowNetwork: true, allowedDomains: [], deniedDomains: [] }; + assert.deepStrictEqual([ + service.isUriAllowed(uri), + service.isUriAllowed(uri, restrictions), + service.isUriAllowed(uri, { ...restrictions, deniedDomains: ['example.com'] }), + service.isUriAllowed(uri, { ...restrictions, sandboxEnabled: false }), + service.isUriAllowed(URI.parse('wss://example.com'), { ...restrictions, allowNetwork: false }), + service.isUriAllowed(URI.file('C:\\test.txt'), { ...restrictions, allowNetwork: false }), + ], [true, true, false, true, false, true]); + }); + test('blocks denied domains', async () => { configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['evil.com']); const service = await createService(); diff --git a/src/vs/platform/sandbox/common/sandboxSettingsResolutionHelper.ts b/src/vs/platform/sandbox/common/sandboxSettingsResolutionHelper.ts index 848659e8a8b0..a7519725a32e 100644 --- a/src/vs/platform/sandbox/common/sandboxSettingsResolutionHelper.ts +++ b/src/vs/platform/sandbox/common/sandboxSettingsResolutionHelper.ts @@ -3,10 +3,37 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AgentSandboxEnabledValue } from './settings.js'; +import { AgentSandboxEnabledSettingValue, AgentSandboxEnabledValue, isAgentSandboxEnabledValue } from './settings.js'; + +/** Effective network restrictions resolved by the Copilot Agent Host for an integrated-browser client tool. */ +export interface ISandboxNetworkRestrictions { + readonly sandboxEnabled: boolean; + readonly allowNetwork: boolean; + readonly allowedDomains: readonly string[]; + readonly deniedDomains: readonly string[]; +} + +export function isSandboxNetworkRestrictions(value: unknown): value is ISandboxNetworkRestrictions { + if (!value || typeof value !== 'object' || !('sandboxEnabled' in value) || !('allowNetwork' in value) + || !('allowedDomains' in value) || !('deniedDomains' in value)) { + return false; + } + return typeof value.sandboxEnabled === 'boolean' && typeof value.allowNetwork === 'boolean' + && Array.isArray(value.allowedDomains) && value.allowedDomains.every(domain => typeof domain === 'string') + && Array.isArray(value.deniedDomains) && value.deniedDomains.every(domain => typeof domain === 'string'); +} /** Resolves user-editable sandbox toggles against the managed floor without changing saved preferences. */ export class SandboxSettingsResolutionHelper { + static getNetworkRestrictions(enabled: AgentSandboxEnabledSettingValue | undefined, allowNetwork: boolean | undefined) { + const sandboxEnabled = isAgentSandboxEnabledValue(enabled); + return { + sandboxEnabled, + allowNetwork: allowNetwork ?? true, + applyDomainRestrictions: sandboxEnabled && (allowNetwork ?? true), + }; + } + static resolveEnabled(local: AgentSandboxEnabledValue | undefined, managed: boolean | undefined): AgentSandboxEnabledValue | undefined { return managed === true ? AgentSandboxEnabledValue.On : local; } diff --git a/src/vs/platform/sandbox/test/common/sandboxSettingsResolutionHelper.test.ts b/src/vs/platform/sandbox/test/common/sandboxSettingsResolutionHelper.test.ts index c168b0b16071..455f7ac69763 100644 --- a/src/vs/platform/sandbox/test/common/sandboxSettingsResolutionHelper.test.ts +++ b/src/vs/platform/sandbox/test/common/sandboxSettingsResolutionHelper.test.ts @@ -29,6 +29,18 @@ suite('SandboxSettingsResolutionHelper', () => { } }); + test('network restrictions distinguish outbound blocking from host filtering', () => { + assert.deepStrictEqual([ + SandboxSettingsResolutionHelper.getNetworkRestrictions(AgentSandboxEnabledValue.Off, true), + SandboxSettingsResolutionHelper.getNetworkRestrictions(AgentSandboxEnabledValue.On, true), + SandboxSettingsResolutionHelper.getNetworkRestrictions(AgentSandboxEnabledValue.On, false), + ], [ + { sandboxEnabled: false, allowNetwork: true, applyDomainRestrictions: false }, + { sandboxEnabled: true, allowNetwork: true, applyDomainRestrictions: true }, + { sandboxEnabled: true, allowNetwork: false, applyDomainRestrictions: false }, + ]); + }); + test('only managed true forces server sandboxing', () => { for (const local of [undefined, false, true]) { assert.deepStrictEqual( diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 178fd515bbc6..e7a188f18cc7 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -14,6 +14,7 @@ import { ITunnelProxyInfo } from '../../../../platform/tunnel/common/tunnelProxy import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { localize } from '../../../../nls.js'; +import { ISandboxNetworkRestrictions } from '../../../../platform/sandbox/common/sandboxSettingsResolutionHelper.js'; import { BrowserHistoryStore, ISerializedBrowserFaviconsSnapshot, @@ -223,6 +224,8 @@ export interface IBrowserViewContextualFilter { } export interface IBrowserViewFilterContext { + /** Restrict visibility to browser storage isolated for this agent session. */ + sandboxSessionId?: string; /** * The session *resource* URI string (`session.resource.toString()`) of the * relevant session, if any. This is the same value stored in @@ -247,6 +250,7 @@ export interface IBrowserViewOpenHandler { } export interface IBrowserViewWorkbenchCreateOptions { + readonly sandboxNetworkRestrictions?: ISandboxNetworkRestrictions; readonly owner: IBrowserViewOwner; readonly session: BrowserViewSessionSelector; readonly initialAudiences?: readonly IBrowserViewAudience[]; @@ -390,6 +394,7 @@ export interface IBrowserViewCDPService { * This model proxies calls to the main process browser view service using its unique ID. */ export interface IBrowserViewModel extends IDisposable { + readonly sandboxSessionId: string | undefined; readonly id: string; readonly host: IBrowserViewHost; readonly owner: IBrowserViewOwner; @@ -503,6 +508,7 @@ export function createBrowserViewEventEmitters(store: DisposableStore): BrowserV } export class BrowserViewModel extends Disposable implements IBrowserViewModel { + readonly sandboxSessionId: string | undefined; private _url: string = ''; private _owner: IBrowserViewOwner; private _title: string = ''; @@ -582,6 +588,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { ) { super(); this._owner = owner; + this.sandboxSessionId = initialState.sandboxSessionId; // Initialize state this._url = initialState.url; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index e3f294fe13d8..d7b89775d5f6 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { BrowserViewChangeEvent, BrowserViewEventData, BrowserViewCommandId, BrowserViewPresentation, BrowserViewStorageScope, IBrowserViewEditorOpenOptions, IBrowserViewInfo, IBrowserViewOwner, IBrowserViewService, IBrowserViewTheme, ipcBrowserViewChannelName, reviveBrowserViewInfo } from '../../../../platform/browserView/common/browserView.js'; +import { matchesBrowserViewSandboxSession } from '../../../../platform/browserView/common/browserViewGroup.js'; import { BrowserViewEventEmitters, createBrowserViewEventEmitters, BrowserViewSharingState, IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel, IBrowserViewContextualFilter, IBrowserViewFilterContext, IBrowserViewOpenHandler, IBrowserViewWorkbenchCreateOptions } from '../common/browserView.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; @@ -248,12 +249,15 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV } getContextualBrowserViews(context?: IBrowserViewFilterContext): Map { - if (this._contextualFilters.size === 0) { + if (this._contextualFilters.size === 0 && context?.sandboxSessionId === undefined) { return this._known; } const filters = [...this._contextualFilters]; const result = new Map(); for (const [id, input] of this._known) { + if (!matchesBrowserViewSandboxSession(input.model?.owner, input.model?.sandboxSessionId, context?.sandboxSessionId)) { + continue; + } if (filters.every(filter => filter.include(input, { ...context }))) { result.set(id, input); } @@ -424,6 +428,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV associatedResource, session: createOptions?.session ?? { scope: await this._resolveStorageScope() }, initialAudiences: createOptions?.initialAudiences, + sandboxNetworkRestrictions: createOptions?.sandboxNetworkRestrictions, initialUrl: createOptions ? createOptions.initialUrl : data.url, openSource: createOptions?.openSource } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index 1392fe6a96de..f02b23be11aa 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -88,10 +88,14 @@ export function getBrowserPagesContext( agentNetworkFilterService: IAgentNetworkFilterService, options?: { activeSessionId?: string; + sandboxSessionId?: string; canPromptUser?: boolean; }, ): string | undefined { - const views = [...browserViewService.getContextualBrowserViews({ activeSessionId: options?.activeSessionId }).values()]; + const views = [...browserViewService.getContextualBrowserViews({ + activeSessionId: options?.activeSessionId, + ...(options?.sandboxSessionId !== undefined ? { sandboxSessionId: options.sandboxSessionId } : {}), + }).values()]; const sharedViews = views.filter(view => view.model?.sharingState === BrowserViewSharingState.Shared); const unsharedCount = views.filter(view => !view.model || view.model.sharingState === BrowserViewSharingState.Available).length; const blockedCount = views.filter(view => view.model?.sharingState === BrowserViewSharingState.BlockedByNetworkPolicy).length; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts index aef4f6bf39a9..f234724e2279 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -7,13 +7,15 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; +import { createSandboxNetworkFilter, IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { ISandboxNetworkRestrictions } from '../../../../../platform/sandbox/common/sandboxSettingsResolutionHelper.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribution } from '../../../../common/contributions.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatContextService } from '../../../chat/browser/contextContrib/chatContextService.js'; import { IChatService } from '../../../chat/common/chatService/chatService.js'; -import { ILanguageModelToolsService, ToolDataSource, ToolSet } from '../../../chat/common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolImpl, ToolDataSource, ToolSet } from '../../../chat/common/tools/languageModelToolsService.js'; import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { getBrowserPagesContext } from './browserToolHelpers.js'; import { ClickBrowserTool, ClickBrowserToolData } from './clickBrowserTool.js'; @@ -89,19 +91,19 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench return; } - this._toolsStore.add(this.toolsService.registerTool(OpenBrowserToolData, this.instantiationService.createInstance(OpenBrowserTool))); - this._toolsStore.add(this.toolsService.registerTool(ReadBrowserToolData, this.instantiationService.createInstance(ReadBrowserTool))); - this._toolsStore.add(this.toolsService.registerTool(ScreenshotBrowserToolData, this.instantiationService.createInstance(ScreenshotBrowserTool))); - this._toolsStore.add(this.toolsService.registerTool(NavigateBrowserToolData, this.instantiationService.createInstance(NavigateBrowserTool))); - this._toolsStore.add(this.toolsService.registerTool(ClickBrowserToolData, this.instantiationService.createInstance(ClickBrowserTool))); - this._toolsStore.add(this.toolsService.registerTool(DragElementToolData, this.instantiationService.createInstance(DragElementTool))); - this._toolsStore.add(this.toolsService.registerTool(HoverElementToolData, this.instantiationService.createInstance(HoverElementTool))); - this._toolsStore.add(this.toolsService.registerTool(TypeBrowserToolData, this.instantiationService.createInstance(TypeBrowserTool))); - this._toolsStore.add(this.toolsService.registerTool(RunPlaywrightCodeToolData, this.instantiationService.createInstance(RunPlaywrightCodeTool))); - this._toolsStore.add(this.toolsService.registerTool(HandleDialogBrowserToolData, this.instantiationService.createInstance(HandleDialogBrowserTool))); + this._registerTool(OpenBrowserToolData, service => service.createInstance(OpenBrowserTool)); + this._registerTool(ReadBrowserToolData, service => service.createInstance(ReadBrowserTool)); + this._registerTool(ScreenshotBrowserToolData, service => service.createInstance(ScreenshotBrowserTool)); + this._registerTool(NavigateBrowserToolData, service => service.createInstance(NavigateBrowserTool)); + this._registerTool(ClickBrowserToolData, service => service.createInstance(ClickBrowserTool)); + this._registerTool(DragElementToolData, service => service.createInstance(DragElementTool)); + this._registerTool(HoverElementToolData, service => service.createInstance(HoverElementTool)); + this._registerTool(TypeBrowserToolData, service => service.createInstance(TypeBrowserTool)); + this._registerTool(RunPlaywrightCodeToolData, service => service.createInstance(RunPlaywrightCodeTool)); + this._registerTool(HandleDialogBrowserToolData, service => service.createInstance(HandleDialogBrowserTool)); // Note: this is not currently exposed directly to models. It is mostly exposed so extensions can use it to provide model context via the API. - this._toolsStore.add(this.toolsService.registerTool(ListBrowserPagesToolData, this.instantiationService.createInstance(ListBrowserPagesTool))); + this._registerTool(ListBrowserPagesToolData, service => service.createInstance(ListBrowserPagesTool)); this._toolsStore.add(this._browserToolSet.addTool(OpenBrowserToolData)); this._toolsStore.add(this._browserToolSet.addTool(ReadBrowserToolData)); @@ -127,6 +129,36 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench this._updateBrowserContext(); } + private _registerTool(data: IToolData, createTool: (service: IInstantiationService) => IToolImpl): void { + const defaultTool = createTool(this.instantiationService); + const withScopedTool = async (restrictions: ISandboxNetworkRestrictions | undefined, run: (tool: IToolImpl) => T | Promise): Promise => { + if (!restrictions) { + return run(defaultTool); + } + const store = new DisposableStore(); + try { + const service = this.instantiationService.createChild(new ServiceCollection( + [IAgentNetworkFilterService, createSandboxNetworkFilter(this.agentNetworkFilterService, restrictions)], + ), store); + return await run(createTool(service)); + } finally { + store.dispose(); + } + }; + const impl: IToolImpl = { + prepareToolInvocation: (context, token) => withScopedTool(context.sandboxNetworkRestrictions, + tool => tool.prepareToolInvocation?.(context, token)), + invoke: async (invocation, countTokens, progress, token) => { + const restrictions = invocation.context?.sandboxNetworkRestrictions; + if (restrictions && invocation.context) { + await this.playwrightService.setSessionNetworkRestrictions(invocation.context.sessionResource.toString(), restrictions); + } + return withScopedTool(restrictions, tool => tool.invoke(invocation, countTokens, progress, token)); + }, + }; + this._toolsStore.add(this.toolsService.registerTool(data, impl)); + } + /** * Subscribe to sharingState changes on each known model so the workspace * context updates whenever a page is shared or unshared. diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/listBrowserPagesTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/listBrowserPagesTool.ts index 421ceb00f8eb..dcc2bf732384 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/listBrowserPagesTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/listBrowserPagesTool.ts @@ -43,7 +43,8 @@ export class ListBrowserPagesTool implements IToolImpl { this.agentNetworkFilterService, { activeSessionId, - canPromptUser: activeSessionId !== undefined, + sandboxSessionId: invocation.context?.sandboxNetworkRestrictions?.sandboxEnabled ? activeSessionId : undefined, + canPromptUser: activeSessionId !== undefined && !invocation.context?.sandboxNetworkRestrictions?.sandboxEnabled, }, ); return { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index d7743634197b..f689cacc93a0 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -27,6 +27,7 @@ import { BrowserChatToolReferenceName } from '../../../../../platform/browserVie import { createBrowserPageLink, errorResult, findExistingPagesByHost, getBrowserNetworkPolicyError, getExistingPagesResult, getExternalTunnelNetworkPolicyError, getSessionId, remoteUrlRewriteNotice, rewriteRemoteLocalhostUrl } from './browserToolHelpers.js'; import { IRemoteExplorerService } from '../../../../services/remote/common/remoteExplorerService.js'; import { getAgentBrowserViewCreationDefaults } from '../../../../../platform/browserView/common/browserView.js'; +import { ISandboxNetworkRestrictions } from '../../../../../platform/sandbox/common/sandboxSettingsResolutionHelper.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; export const OpenPageToolId = 'open_browser_page'; @@ -84,6 +85,9 @@ export class OpenBrowserTool implements IToolImpl { const params = context.parameters as IOpenBrowserToolParams; if (!params.url) { + if (context.sandboxNetworkRestrictions?.sandboxEnabled) { + throw new Error(localize('browser.sandbox.requiresUrl', "Sandboxed Copilot sessions must open a URL in an isolated browser page instead of sharing a user-owned page.")); + } return { invocationMessage: localize('browser.open.prompt.invocation', "Prompting user to share a browser tab"), pastTenseMessage: localize('browser.open.prompt.past', "Prompted user to share a browser tab"), @@ -117,6 +121,23 @@ export class OpenBrowserTool implements IToolImpl { const params = invocation.parameters as IOpenBrowserToolParams; const sessionId = getSessionId(invocation); const activeSessionId = invocation.context?.sessionResource.toString(); + const restrictions = invocation.context?.sandboxNetworkRestrictions; + if (restrictions?.sandboxEnabled) { + if (!params.url) { + return errorResult(localize('browser.sandbox.requiresUrl', "Sandboxed Copilot sessions must open a URL in an isolated browser page instead of sharing a user-owned page.")); + } + const policyError = getBrowserNetworkPolicyError(params.url, this.agentNetworkFilterService); + if (policyError) { + return errorResult(policyError); + } + const rewrite = rewriteRemoteLocalhostUrl(params.url, this.browserViewService, this.remoteExplorerService); + const tunnelError = getExternalTunnelNetworkPolicyError(rewrite, this.agentNetworkFilterService); + if (tunnelError) { + return errorResult(tunnelError); + } + const result = await this._openNewPage(sessionId, rewrite.url, restrictions); + return rewrite.rewritten ? { ...result, content: [remoteUrlRewriteNotice(params.url, rewrite.url), ...result.content] } : result; + } // If no URL is specified, prompt the user for a page to share. if (!params.url) { @@ -169,7 +190,7 @@ export class OpenBrowserTool implements IToolImpl { } } - return withNotice(await this._openNewPage(sessionId, params.url)); + return withNotice(await this._openNewPage(sessionId, params.url, restrictions)); } /** @@ -305,9 +326,11 @@ export class OpenBrowserTool implements IToolImpl { return undefined; } - private async _openNewPage(sessionId: string, url: string): Promise { + private async _openNewPage(sessionId: string, url: string, restrictions?: ISandboxNetworkRestrictions): Promise { const input = await this.browserViewService.createBrowserView({ - ...getAgentBrowserViewCreationDefaults(sessionId, this.environmentService.isSessionsWindow ? sessionId : undefined), + ...getAgentBrowserViewCreationDefaults(sessionId, restrictions || this.environmentService.isSessionsWindow ? sessionId : undefined), + ...(restrictions?.sandboxEnabled ? { initialAudiences: [{ type: 'agent' as const, sessionId }] } : {}), + ...(restrictions ? { sandboxNetworkRestrictions: restrictions } : {}), initialUrl: url, openSource: 'cdpCreated' }, { preserveFocus: true }); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts index 9317ae77086b..c2699f82c782 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts @@ -14,13 +14,15 @@ import { getWindowById } from '../../../../../base/browser/dom.js'; import { getZoomFactor } from '../../../../../base/browser/browser.js'; import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { matchesBrowserViewSandboxSession } from '../../../../../platform/browserView/common/browserViewGroup.js'; +import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; import { readImageDimensions } from '../../../../../base/common/image.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { GroupsOrder, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { BrowserViewSharingState, IBrowserViewModel, IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; -import { errorResult, getSessionId, playwrightInvokeRaw } from './browserToolHelpers.js'; +import { errorResult, getBrowserNetworkPolicyError, getSessionId, playwrightInvokeRaw } from './browserToolHelpers.js'; import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; import { ReadBrowserToolData } from './readBrowserTool.js'; @@ -141,6 +143,7 @@ export class ScreenshotBrowserTool implements IToolImpl { @IPlaywrightService private readonly playwrightService: IPlaywrightService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, ) { } async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -177,6 +180,15 @@ export class ScreenshotBrowserTool implements IToolImpl { if (!browserViewModel || browserViewModel.sharingState !== BrowserViewSharingState.Shared) { return errorResult(`No browser page found with ID ${params.pageId}`); } + if (invocation.context?.sandboxNetworkRestrictions?.sandboxEnabled) { + if (!matchesBrowserViewSandboxSession(browserViewModel.owner, browserViewModel.sandboxSessionId, sessionId)) { + return errorResult(localize('browser.sandbox.pageNotAvailable', "No browser page found with ID {0} in this sandboxed session.", params.pageId)); + } + const policyError = getBrowserNetworkPolicyError(browserViewModel.url, this.agentNetworkFilterService); + if (policyError) { + return errorResult(policyError); + } + } const bounds = selector && await playwrightInvokeRaw(this.playwrightService, sessionId, params.pageId, async (page, selector, scrollIntoViewIfNeeded) => { const locator = page.locator(selector); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts index 937ebb70ebd2..cf7765eb8e90 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts @@ -122,10 +122,10 @@ suite('OpenBrowserTool', () => { CancellationToken.None ); - assert.deepStrictEqual({ createOptions, editorOpenOptions, summaryArguments }, { + const expected = { createOptions: { - owner: { type: 'agent', sessionId: 'chat:session' }, - initialAudiences: [{ type: 'agent' }], + owner: { type: 'agent' as const, sessionId: 'chat:session' }, + initialAudiences: [{ type: 'agent' as const }], session: { scope: BrowserViewStorageScope.Agent, affinity: 'chat:session' @@ -135,6 +135,26 @@ suite('OpenBrowserTool', () => { }, editorOpenOptions: { preserveFocus: true }, summaryArguments: ['chat:session', 'page-id', 'https://example.com', 5000] + }; + assert.deepStrictEqual({ createOptions, editorOpenOptions, summaryArguments }, expected); + + const restrictions = { sandboxEnabled: true, allowNetwork: true, allowedDomains: ['example.com'], deniedDomains: [] }; + await tool.invoke( + upcastPartial({ + parameters: { url: 'https://example.com', forceNew: false }, + context: { sessionResource: URI.parse('chat:session'), sandboxNetworkRestrictions: restrictions } + }), + async () => 0, + upcastPartial({ report: () => { } }), + CancellationToken.None + ); + assert.deepStrictEqual({ createOptions, editorOpenOptions, summaryArguments }, { + ...expected, + createOptions: { + ...expected.createOptions, + initialAudiences: [{ type: 'agent', sessionId: 'chat:session' }], + sandboxNetworkRestrictions: restrictions, + }, }); }); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/screenshotBrowserTool.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/screenshotBrowserTool.test.ts new file mode 100644 index 000000000000..1d70220ecb49 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/screenshotBrowserTool.test.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IPlaywrightService } from '../../../../../../platform/browserView/common/playwrightService.js'; +import { IAgentNetworkFilterService } from '../../../../../../platform/networkFilter/common/networkFilterService.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; +import { BrowserEditorInput } from '../../../common/browserEditorInput.js'; +import { BrowserViewSharingState, IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../common/browserView.js'; +import { ScreenshotBrowserTool } from '../../../electron-browser/tools/screenshotBrowserTool.js'; + +suite('ScreenshotBrowserTool', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('sandboxed Copilot screenshots reject shared user pages and policy-blocked isolated pages before capture', async () => { + const restrictions = { sandboxEnabled: true, allowNetwork: true, allowedDomains: [], deniedDomains: ['blocked.example'] }; + const models = [ + upcastPartial({ owner: { type: 'user' }, url: 'https://allowed.example' }), + upcastPartial({ owner: { type: 'agent', sessionId: 'chat:session' }, sandboxSessionId: 'chat:session', url: 'https://blocked.example' }), + ]; + const errors: (string | boolean | undefined)[] = []; + for (const model of models) { + const sharedModel = upcastPartial({ + ...model, + sharingState: BrowserViewSharingState.Shared, + captureScreenshot: async () => { throw new Error('Must not capture a disallowed page'); }, + }); + const input = upcastPartial({ resolve: async () => sharedModel }); + const tool = new ScreenshotBrowserTool( + upcastPartial({ getKnownBrowserViews: () => new Map([['page', input]]) }), + upcastPartial({}), + upcastPartial({}), + upcastPartial({}), + upcastPartial({ isUriAllowed: () => false, formatError: () => 'Blocked by session network policy' }), + ); + const result = await tool.invoke({ + callId: 'test', toolId: 'screenshot_page', parameters: { pageId: 'page' }, + context: { sessionResource: URI.parse('chat:session'), sandboxNetworkRestrictions: restrictions }, + }, async () => 0, { report: () => { } }, CancellationToken.None); + errors.push(result.toolResultError); + } + assert.deepStrictEqual(errors, [ + 'No browser page found with ID page in this sandboxed session.', + 'Blocked by session network policy', + ]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 4eee6698e5d7..c2d81421f04b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -3066,7 +3066,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC callId: toolCall.toolCallId, toolId: toolData.id, parameters, - context: contextSessionResource ? { sessionResource: contextSessionResource } : undefined, + context: contextSessionResource ? { + sessionResource: contextSessionResource, + sandboxNetworkRestrictions: readToolCallMeta(toolCall)['vscode.copilotSandboxNetworkRestrictions'], + } : undefined, chatStreamToolCallId: toolCall.toolCallId, preApproved: toolCall.status === ToolCallStatus.PendingConfirmation ? undefined : getClientToolPreApproval(toolCall), }, async () => 0, token); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 67f779289c71..018ab2f94f6e 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -62,6 +62,7 @@ import { ChatModeService, IChatMode, IChatModeService, IChatModes } from '../com import { IChatService } from '../common/chatService/chatService.js'; import { ChatRequestOriginService, IChatRequestOriginService } from '../common/chatRequestOrigin.js'; import { ChatService } from '../common/chatService/chatServiceImpl.js'; +import { chatNetworkDomainConfigurationMigrations, chatNetworkDomainConfigurationProperties } from './chatNetworkConfiguration.js'; import { IChatSessionsService } from '../common/chatSessionsService.js'; import { ChatSideChatService, IChatSideChatService } from '../common/chatSideChatService.js'; import { BYOKUtilityModelDefault, ChatAIDisabledSettingId, ChatAgentLocation, ChatConfiguration, ChatClosedPromoNotification, ChatDefaultPermissionLevel, ChatNotificationMode, ChatPermissionLevel } from '../common/constants.js'; @@ -1567,46 +1568,7 @@ configurationRegistry.registerConfiguration({ } } }, - [AgentNetworkDomainSettingId.AllowedNetworkDomains]: { - keywords: ['Sandbox', 'sandboxing'], - markdownDescription: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. When {2} is enabled, all domains are allowed. Supports wildcards like {3}. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see {4}) take precedence.\n\nChanges may not take full effect until VS Code is restarted.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`#${AgentSandboxSettingId.AgentSandboxAllowNetwork}#\``, '`*.example.com`', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), - type: 'array', - items: { type: 'string' }, - default: [], - scope: ConfigurationScope.APPLICATION, - restricted: true, - policy: { - name: 'ChatAgentAllowedNetworkDomains', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.116', - localization: { - description: { - key: 'chat.agent.allowedNetworkDomains', - value: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. When {2} is enabled, all domains are allowed. Supports wildcards like {3}. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see {4}) take precedence.\n\nChanges may not take full effect until VS Code is restarted.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`#${AgentSandboxSettingId.AgentSandboxAllowNetwork}#\``, '`*.example.com`', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), - } - } - } - }, - [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { - keywords: ['Sandbox', 'sandboxing'], - markdownDescription: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. This does not apply when {2} is enabled. Takes precedence over {3}. Supports wildcards like {4}.\n\nChanges may not take full effect until VS Code is restarted.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`#${AgentSandboxSettingId.AgentSandboxAllowNetwork}#\``, `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, '`*.example.com`'), - type: 'array', - items: { type: 'string' }, - default: [], - scope: ConfigurationScope.APPLICATION, - restricted: true, - policy: { - name: 'ChatAgentDeniedNetworkDomains', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.116', - localization: { - description: { - key: 'chat.agent.deniedNetworkDomains', - value: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. This does not apply when {2} is enabled. Takes precedence over {3}. Supports wildcards like {4}.\n\nChanges may not take full effect until VS Code is restarted.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`#${AgentSandboxSettingId.AgentSandboxAllowNetwork}#\``, `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, '`*.example.com`'), - } - } - } - }, + ...chatNetworkDomainConfigurationProperties, [ChatConfiguration.DefaultNewSessionMode]: { type: 'string', description: nls.localize('chat.newSession.defaultMode', "The default mode for new chat sessions. When empty, the chat view's default mode is used."), @@ -2643,6 +2605,7 @@ function migrateAgentMergeMergePullRequest(value: unknown): unknown { } Registry.as(Extensions.ConfigurationMigration).registerConfigurationMigrations([ + ...chatNetworkDomainConfigurationMigrations, { key: 'chat.agentSessions.defaultConfiguration', migrateFn: (value, _accessor) => ([ diff --git a/src/vs/workbench/contrib/chat/browser/chatNetworkConfiguration.ts b/src/vs/workbench/contrib/chat/browser/chatNetworkConfiguration.ts new file mode 100644 index 000000000000..0274ccd17c6c --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatNetworkConfiguration.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PolicyCategory } from '../../../../base/common/policy.js'; +import * as nls from '../../../../nls.js'; +import { ConfigurationScope, IConfigurationPropertySchema } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; +import { AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js'; +import { ConfigurationKeyValuePairs, ConfigurationMigration } from '../../../common/configuration.js'; + +export const chatNetworkDomainConfigurationProperties: Record = { + [AgentNetworkDomainSettingId.AllowedNetworkDomains]: { + order: 66, + keywords: ['Sandbox', 'sandboxing'], + markdownDescription: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser) when {0} is enabled. In Copilot Agent Host sessions with sandboxing ({1}) enabled, also restricts the integrated browser when outbound network access ({2}) is allowed; an empty allow list adds no sandbox restriction. This list does not enable outbound access. Supports wildcards like {3}. Outside sandboxing, when both allowed and denied lists are empty, all domains are blocked. Denied domains (see {4}) take precedence.\n\nChanges may not take full effect until VS Code is restarted.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`#${AgentSandboxSettingId.AgentSandboxAllowNetwork}#\``, '`*.example.com`', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), + type: 'array', + items: { type: 'string' }, + default: [], + scope: ConfigurationScope.APPLICATION, + restricted: true, + policy: { + name: 'ChatAgentAllowedNetworkDomains', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.116', + localization: { + description: { + key: 'chat.agent.allowedNetworkDomains', + value: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser) when {0} is enabled. In Copilot Agent Host sessions with sandboxing ({1}) enabled, also restricts the integrated browser when outbound network access ({2}) is allowed; an empty allow list adds no sandbox restriction. This list does not enable outbound access. Supports wildcards like {3}. Outside sandboxing, when both allowed and denied lists are empty, all domains are blocked. Denied domains (see {4}) take precedence.\n\nChanges may not take full effect until VS Code is restarted.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`#${AgentSandboxSettingId.AgentSandboxAllowNetwork}#\``, '`*.example.com`', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), + } + } + } + }, + [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { + order: 67, + keywords: ['Sandbox', 'sandboxing'], + markdownDescription: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser) when {0} is enabled. Also applies to the integrated browser in sandboxed Copilot Agent Host sessions ({1}); allowing outbound network access ({2}) does not bypass this list. Takes precedence over {3}. Supports wildcards like {4}.\n\nChanges may not take full effect until VS Code is restarted.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`#${AgentSandboxSettingId.AgentSandboxAllowNetwork}#\``, `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, '`*.example.com`'), + type: 'array', + items: { type: 'string' }, + default: [], + scope: ConfigurationScope.APPLICATION, + restricted: true, + policy: { + name: 'ChatAgentDeniedNetworkDomains', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.116', + localization: { + description: { + key: 'chat.agent.deniedNetworkDomains', + value: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser) when {0} is enabled. Also applies to the integrated browser in sandboxed Copilot Agent Host sessions ({1}); allowing outbound network access ({2}) does not bypass this list. Takes precedence over {3}. Supports wildcards like {4}.\n\nChanges may not take full effect until VS Code is restarted.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`#${AgentSandboxSettingId.AgentSandboxAllowNetwork}#\``, `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, '`*.example.com`'), + } + } + } + }, +}; + +export const chatNetworkDomainConfigurationMigrations: ConfigurationMigration[] = [ + ...[ + ['chat.agent.allowedNetworkDomains', AgentNetworkDomainSettingId.AllowedNetworkDomains], + ['chat.agent.deniedNetworkDomains', AgentNetworkDomainSettingId.DeniedNetworkDomains], + ].map(([key, newKey]) => ({ + key, + includeApplication: true, + migrateFn: (value: unknown, accessor: (key: string) => unknown): ConfigurationKeyValuePairs => { + const pairs: ConfigurationKeyValuePairs = [[key, { value: undefined }]]; + if (accessor(newKey) === undefined) { + pairs.push([newKey, { value }]); + } + return pairs; + } + })), +]; diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index ca5ceb902a06..ab143449b952 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -1046,6 +1046,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo toolCallId: dto.callId, chatRequestId: dto.chatRequestId, chatSessionResource: dto.context?.sessionResource, + sandboxNetworkRestrictions: dto.context?.sandboxNetworkRestrictions, chatInteractionId: dto.chatInteractionId, invocationRequestId: dto.context?.requestId, modelId: dto.modelId, diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 601130d5961d..d0e4d0068178 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -18,6 +18,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { Location } from '../../../../../editor/common/languages.js'; import { localize } from '../../../../../nls.js'; import { ConfirmationOption } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ISandboxNetworkRestrictions } from '../../../../../platform/sandbox/common/sandboxSettingsResolutionHelper.js'; import { ContextKeyExpression, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { ByteSize } from '../../../../../platform/files/common/files.js'; @@ -222,6 +223,7 @@ export interface IToolInvocation { } export interface IToolInvocationContext { + readonly sandboxNetworkRestrictions?: ISandboxNetworkRestrictions; readonly sessionResource: URI; /** * The id of the chat request that the tool is invoked for. For a subagent @@ -242,6 +244,7 @@ export function isToolInvocationContext(obj: any): obj is IToolInvocationContext } export interface IToolInvocationPreparationContext { + readonly sandboxNetworkRestrictions?: ISandboxNetworkRestrictions; // eslint-disable-next-line @typescript-eslint/no-explicit-any parameters: any; toolCallId: string; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatNetworkConfiguration.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatNetworkConfiguration.test.ts new file mode 100644 index 000000000000..99503cfcea12 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatNetworkConfiguration.test.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AgentNetworkDomainSettingId } from '../../../../../platform/networkFilter/common/settings.js'; +import { AgentSandboxSettingId } from '../../../../../platform/sandbox/common/settings.js'; +import { terminalContribConfiguration } from '../../../terminal/terminalContribExports.js'; +import { chatNetworkDomainConfigurationMigrations, chatNetworkDomainConfigurationProperties } from '../../browser/chatNetworkConfiguration.js'; + +suite('Chat network configuration', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('orders sandbox domain settings after user-configured paths and preserves policy names', () => { + const properties = { + ...terminalContribConfiguration, + ...chatNetworkDomainConfigurationProperties, + }; + assert.deepStrictEqual([ + AgentSandboxSettingId.AgentSandboxUserConfiguredPaths, + AgentNetworkDomainSettingId.AllowedNetworkDomains, + AgentNetworkDomainSettingId.DeniedNetworkDomains, + ].map(key => ({ + key, + order: properties[key].order, + policy: properties[key].policy?.name, + })), [ + { key: 'chat.agent.sandbox.fileSystem.userConfiguredPaths', order: 65, policy: undefined }, + { key: 'chat.agent.sandbox.network.allowedDomains', order: 66, policy: 'ChatAgentAllowedNetworkDomains' }, + { key: 'chat.agent.sandbox.network.deniedDomains', order: 67, policy: 'ChatAgentDeniedNetworkDomains' }, + ]); + }); + + for (const [oldKey, newKey] of [ + ['chat.agent.allowedNetworkDomains', AgentNetworkDomainSettingId.AllowedNetworkDomains], + ['chat.agent.deniedNetworkDomains', AgentNetworkDomainSettingId.DeniedNetworkDomains], + ]) { + test(`migrates ${oldKey} including application settings without overwriting the new key`, async () => { + const migration = chatNetworkDomainConfigurationMigrations.find(migration => migration.key === oldKey); + assert.deepStrictEqual({ + includeApplication: migration?.includeApplication, + migrated: await migration?.migrateFn(['example.com'], () => undefined), + preserved: await migration?.migrateFn(['example.com'], () => ['existing.com']), + preservedEmpty: await migration?.migrateFn(['example.com'], () => []), + }, { + includeApplication: true, + migrated: [[oldKey, { value: undefined }], [newKey, { value: ['example.com'] }]], + preserved: [[oldKey, { value: undefined }]], + preservedEmpty: [[oldKey, { value: undefined }]], + }); + }); + } +}); diff --git a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts index 14f657cdc074..eeb0a0f6d6f6 100644 --- a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts +++ b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts @@ -15,6 +15,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { AgentNetworkDomainSettingId } from '../../../../../platform/networkFilter/common/settings.js'; import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_SANDBOX_LSP_SERVERS_KEY, COPILOT_SANDBOX_MCP_SERVERS_KEY, IManagedSettingsService, NullManagedSettingsService } from '../../../../../platform/policy/common/copilotManagedSettings.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../platform/sandbox/common/settings.js'; import { IWorkbenchConfigurationService } from '../../../../services/configuration/common/configuration.js'; @@ -28,6 +29,7 @@ import { SettingsTarget } from '../../browser/preferencesWidgets.js'; import { LayoutSettings, ModernUIDensity } from '../../../../services/layout/browser/layoutService.js'; import { IManagedSettingsPresentationService, ManagedSettingsPresentationService } from '../../../../services/configuration/common/managedSettingsPresentation.js'; import { terminalContribConfiguration } from '../../../terminal/terminalContribExports.js'; +import { SettingMatches } from '../../browser/preferencesSearch.js'; suite('SettingsTree Agents Window density', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -472,6 +474,53 @@ suite('SettingsTree deprecation warnings', () => { } }); +suite('SettingsTree sandbox network search ordering', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('sandbox search puts user-configured paths before allowed and denied domains', () => { + const configuration = new class extends TestConfigurationService { + isSettingAppliedForAllProfiles(): boolean { return false; } + }(); + store.add(configuration.onDidChangeConfigurationEmitter); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IWorkbenchConfigurationService, configuration); + instantiationService.stub(ILanguageService, { isRegisteredLanguageId: () => true }); + instantiationService.stub(IUserDataProfileService, new TestUserDataProfileService()); + instantiationService.stub(IProductService, TestProductService); + instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow: false }); + instantiationService.stub(IExperimentalSettingsService, store.add(new ExperimentalSettingsService())); + instantiationService.stub(IManagedSettingsService, new NullManagedSettingsService()); + instantiationService.stub(IManagedSettingsPresentationService, store.add(instantiationService.createInstance(ManagedSettingsPresentationService))); + const keys = [ + AgentSandboxSettingId.AgentSandboxUserConfiguredPaths, + AgentNetworkDomainSettingId.AllowedNetworkDomains, + AgentNetworkDomainSettingId.DeniedNetworkDomains, + ]; + const model = store.add(instantiationService.createInstance(SearchResultModel, + { settingsTarget: ConfigurationTarget.USER_LOCAL, query: 'sandbox' }, + new Map(keys.map((key, index) => [key, index])), true)); + const matches = [...keys].reverse().map(key => { + const setting = new class extends mock() { + override key = key; + override type = 'array'; + override description = []; + override scope = ConfigurationScope.APPLICATION; + override keyRange = { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: key.length + 1 }; + }(); + const match = new SettingMatches('sandbox', setting, true, configuration); + return { setting, matches: match.matches, matchType: match.matchType, keyMatchScore: match.keyMatchScore, score: 0 }; + }); + model.setResult(SearchResultIdx.Local, { filterMatches: matches, exactMatch: false }); + assert.deepStrictEqual(model.getUniqueSearchResults()?.filterMatches.map(match => ({ + key: match.setting.key, + matchType: match.matchType, + })), keys.map(key => ({ + key, + matchType: SettingMatchType.AllWordsInSettingsLabel | SettingMatchType.ContiguousQueryInSettingId, + }))); + }); +}); + suite('SettingsTree', () => { test('settingKeyToDisplayFormat - sandbox outbound connections', () => { assert.deepStrictEqual([ @@ -489,6 +538,32 @@ suite('SettingsTree', () => { ]); }); + test('settingKeyToDisplayFormat - allowed network domains', () => { + assert.deepStrictEqual([ + settingKeyToDisplayFormat(AgentNetworkDomainSettingId.AllowedNetworkDomains), + settingKeyToDisplayFormat(AgentNetworkDomainSettingId.AllowedNetworkDomains, 'chat'), + settingKeyToDisplayFormat(AgentNetworkDomainSettingId.AllowedNetworkDomains, 'chat.agent.sandbox'), + settingKeyToDisplayFormat(AgentNetworkDomainSettingId.AllowedNetworkDomains, 'chat.agent.sandbox.network'), + ], [ + { category: 'Chat › Agent › Sandbox › Network', label: 'Allowed Domains' }, + { category: 'Agent › Sandbox › Network', label: 'Allowed Domains' }, + { category: 'Network', label: 'Allowed Domains' }, + { category: '', label: 'Allowed Domains' }, + ]); + }); + + test('settingKeyToDisplayFormat - denied network domains', () => { + assert.deepStrictEqual([ + settingKeyToDisplayFormat(AgentNetworkDomainSettingId.DeniedNetworkDomains), + settingKeyToDisplayFormat(AgentNetworkDomainSettingId.DeniedNetworkDomains, 'chat.agent.sandbox'), + settingKeyToDisplayFormat(AgentNetworkDomainSettingId.DeniedNetworkDomains, 'chat.agent.sandbox.network'), + ], [ + { category: 'Chat › Agent › Sandbox › Network', label: 'Denied Domains' }, + { category: 'Network', label: 'Denied Domains' }, + { category: '', label: 'Denied Domains' }, + ]); + }); + test('settingKeyToDisplayFormat - sandbox bypass', () => { assert.deepStrictEqual([ settingKeyToDisplayFormat(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands), diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 028dac2e4166..e46c8cef53bb 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1327,15 +1327,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { private _getBlockedDomainReason(blockedDomains: string[], deniedDomains: string[] = []): string { if (deniedDomains.length === blockedDomains.length && deniedDomains.length > 0) { if (blockedDomains.length === 1) { - return localize('runInTerminal.unsandboxed.domain.reason.denied.single', "This command accesses {0}, which is blocked by chat.agent.deniedNetworkDomains.", blockedDomains[0]); + return localize('runInTerminal.unsandboxed.domain.reason.denied.single', "This command accesses {0}, which is blocked by chat.agent.sandbox.network.deniedDomains.", blockedDomains[0]); } - return localize('runInTerminal.unsandboxed.domain.reason.denied.multi', "This command accesses {0} and {1} more domains that are blocked by chat.agent.deniedNetworkDomains.", blockedDomains[0], blockedDomains.length - 1); + return localize('runInTerminal.unsandboxed.domain.reason.denied.multi', "This command accesses {0} and {1} more domains that are blocked by chat.agent.sandbox.network.deniedDomains.", blockedDomains[0], blockedDomains.length - 1); } if (deniedDomains.length > 0) { if (blockedDomains.length === 1) { - return localize('runInTerminal.unsandboxed.domain.reason.mixed.single', "This command accesses {0}, which is blocked by chat.agent.deniedNetworkDomains or not added to chat.agent.allowedNetworkDomains.", blockedDomains[0]); + return localize('runInTerminal.unsandboxed.domain.reason.mixed.single', "This command accesses {0}, which is blocked by chat.agent.sandbox.network.deniedDomains or not added to chat.agent.sandbox.network.allowedDomains.", blockedDomains[0]); } - return localize('runInTerminal.unsandboxed.domain.reason.mixed.multi', "This command accesses {0} and {1} more domains that are blocked by chat.agent.deniedNetworkDomains or not added to chat.agent.allowedNetworkDomains.", blockedDomains[0], blockedDomains.length - 1); + return localize('runInTerminal.unsandboxed.domain.reason.mixed.multi', "This command accesses {0} and {1} more domains that are blocked by chat.agent.sandbox.network.deniedDomains or not added to chat.agent.sandbox.network.allowedDomains.", blockedDomains[0], blockedDomains.length - 1); } if (blockedDomains.length === 1) { return localize('runInTerminal.unsandboxed.domain.reason.single', "This command accesses {0}, which is not permitted by the current chat.agent.sandbox configuration.", blockedDomains[0]); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index edf4d379ebde..7ac8ed474a06 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -569,7 +569,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, read(COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY) !== false), order: 20, keywords: ['Sandbox', 'sandboxing'], - markdownDescription: localize('agentSandbox.allowNetwork', "Allow the sandboxed process to reach the Internet."), + markdownDescription: localize('agentSandbox.allowNetwork', "Allow outbound network access in the sandbox (`allowOutbound` in the SDK). Configured allowed and denied network domains still restrict destinations. When disabled, outbound network access is blocked, including for the integrated browser."), type: 'boolean', default: true, restricted: true, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 5fc1f8d7e6d5..d0912a8d5f51 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -1410,7 +1410,7 @@ suite('RunInTerminalTool', () => { if (!confirmationMessage || typeof confirmationMessage === 'string') { throw new Error('Expected markdown confirmation message'); } - ok(confirmationMessage.value.includes('Reason for leaving the sandbox: This command accesses evil.com, which is blocked by chat.agent.deniedNetworkDomains.')); + ok(confirmationMessage.value.includes('Reason for leaving the sandbox: This command accesses evil.com, which is blocked by chat.agent.sandbox.network.deniedDomains.')); }); test('should force confirmation for explicit sandboxed allow-network requests', async () => { @@ -1472,7 +1472,7 @@ suite('RunInTerminalTool', () => { if (!confirmationMessage || typeof confirmationMessage === 'string') { throw new Error('Expected markdown confirmation message'); } - ok(confirmationMessage.value.includes('Reason for allowing unrestricted network access in the sandbox: This command accesses evil.com, which is blocked by chat.agent.deniedNetworkDomains.')); + ok(confirmationMessage.value.includes('Reason for allowing unrestricted network access in the sandbox: This command accesses evil.com, which is blocked by chat.agent.sandbox.network.deniedDomains.')); }); test('should reject explicit allow-network requests when per-command network access is disabled', async () => { From 5e86c7c4c2eb99b22e631e0aa4eb05f6b8b53f35 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Sun, 4 Oct 2026 03:35:54 -0700 Subject: [PATCH 11/49] Copilot: migrate embedding-backed features to Metis (#339532) * Copilot: migrate embeddings to Metis Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle additional file not found error code Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../context/node/resolvers/extensionApi.tsx | 6 +- .../vscode-node/endpointProviderImpl.ts | 10 +- .../settingsEditorSearchServiceImpl.ts | 2 +- .../node/panel/newWorkspace/newWorkspace.tsx | 2 +- .../extension/prompts/node/panel/vscode.tsx | 2 +- .../test/vscode-node/endpoints.test.ts | 32 ++++- .../preComputedToolEmbeddingsCache.ts | 29 +++-- .../virtualTools/toolEmbeddingsComputer.ts | 4 +- .../common/virtualTools/virtualToolGrouper.ts | 2 +- .../preComputedToolEmbeddingsCache.spec.ts | 50 ++++++++ .../embeddings/common/embeddingsIndex.ts | 13 +- .../common/remoteEmbeddingsComputer.ts | 31 +++-- .../platform/embeddings/common/vscodeIndex.ts | 20 +-- .../node/remoteEmbeddingsComputer.spec.ts | 114 ++++++++++++++++++ .../endpoint/node/modelMetadataFetcher.ts | 6 +- .../common/projectTemplatesIndex.ts | 4 +- 16 files changed, 267 insertions(+), 60 deletions(-) create mode 100644 extensions/copilot/src/extension/tools/test/node/virtualTools/preComputedToolEmbeddingsCache.spec.ts create mode 100644 extensions/copilot/src/platform/embeddings/test/node/remoteEmbeddingsComputer.spec.ts diff --git a/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx b/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx index db57b8976ff4..46d7551ed382 100644 --- a/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx +++ b/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx @@ -32,8 +32,8 @@ export class ApiEmbeddingsIndex implements IApiEmbeddingsIndex { ) { const cacheVersion = sanitizeVSCodeVersion(envService.getEditorInfo().version); this.embeddingsCache = useRemoteCache ? - instantiationService.createInstance(RemoteEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'api', cacheVersion, EmbeddingType.text3small_512, RemoteCacheType.Api) : - instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'api', cacheVersion, EmbeddingType.text3small_512); + instantiationService.createInstance(RemoteEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'api', cacheVersion, EmbeddingType.metis_1024_I16_Binary, RemoteCacheType.Api) : + instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'api', cacheVersion, EmbeddingType.metis_1024_I16_Binary); } async updateIndex(): Promise { @@ -95,7 +95,7 @@ export class VSCodeAPIContextElement extends PromptElement = { + text3small: 'text-embedding-3-small', + metis: 'metis', +}; // Keep in sync with `BYOKUtilityModelDefault` in `src/vs/workbench/contrib/chat/common/constants.ts` and the `chat.byokUtilityModelDefault` enum in `chat.shared.contribution.ts`. const enum BYOKUtilityModelDefault { @@ -353,14 +357,14 @@ export class ProductionEndpointProvider extends Disposable implements IEndpointP async getEmbeddingsEndpoint(family?: EmbeddingsEndpointFamily): Promise { this._logService.trace(`Resolving embedding model`); - const modelMetadata = await this._modelFetcher.getEmbeddingsModel('text-embedding-3-small'); + const modelMetadata = await this._modelFetcher.getEmbeddingsModel(embeddingsModelFamilies[family ?? 'metis']); const model = await this.getOrCreateEmbeddingEndpointInstance(modelMetadata); this._logService.trace(`Resolved embedding model`); return model; } private async getOrCreateEmbeddingEndpointInstance(modelMetadata: IEmbeddingModelInformation): Promise { - const modelId = 'text-embedding-3-small'; + const modelId = modelMetadata.id; let embeddingEndpoint = this._embeddingEndpoints.get(modelId); if (!embeddingEndpoint) { embeddingEndpoint = this._instantiationService.createInstance(EmbeddingEndpoint, modelMetadata); diff --git a/extensions/copilot/src/extension/prompt/vscode-node/settingsEditorSearchServiceImpl.ts b/extensions/copilot/src/extension/prompt/vscode-node/settingsEditorSearchServiceImpl.ts index c319e4bb0a6e..43b942109108 100644 --- a/extensions/copilot/src/extension/prompt/vscode-node/settingsEditorSearchServiceImpl.ts +++ b/extensions/copilot/src/extension/prompt/vscode-node/settingsEditorSearchServiceImpl.ts @@ -32,7 +32,7 @@ export class SettingsEditorSearchServiceImpl implements ISettingsEditorSearchSer // Start searching for embedding results. let embeddingResult: Embeddings; try { - embeddingResult = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.text3small_512, [query], {}, new TelemetryCorrelationId('SettingsEditorSearchServiceImpl::provideSettingsSearchResults'), token); + embeddingResult = await this.embeddingsComputer.computeEmbeddings(EmbeddingType.metis_1024_I16_Binary, [query], { inputType: 'query' }, new TelemetryCorrelationId('SettingsEditorSearchServiceImpl::provideSettingsSearchResults'), token); } catch { this.reportEmptyEmbeddingsResult(query, progress); if (!options.embeddingsOnly) { diff --git a/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx b/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx index c2865f146940..eb25085c33b1 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx @@ -105,7 +105,7 @@ export class NewWorkspacePrompt extends PromptElement { + async getEmbeddingsModel(family: 'text-embedding-3-small' | 'metis'): Promise { return { - id: 'text-embedding-3-small', + id: family, name: 'fake-name', vendor: 'fake-vendor', version: 'fake-version', @@ -68,7 +68,7 @@ class FakeModelMetadataFetcher implements IModelMetadataFetcher { capabilities: { type: 'embeddings', tokenizer: TokenizerType.O200K, - family: 'text-embedding-3-small', + family, limits: { max_inputs: 256 } } }; @@ -102,6 +102,26 @@ suite('Endpoint Class Test', function () { assert.strictEqual(endpoint.model, 'gpt-5.4-nano'); }); + + test('resolves embedding endpoints by family with Metis as the default', async function () { + const defaultEndpoint = await endpointProvider.getEmbeddingsEndpoint(); + const metisEndpoint = await endpointProvider.getEmbeddingsEndpoint('metis'); + const text3smallEndpoint = await endpointProvider.getEmbeddingsEndpoint('text3small'); + + assert.deepStrictEqual({ + defaultFamily: defaultEndpoint.family, + metisFamily: metisEndpoint.family, + text3smallFamily: text3smallEndpoint.family, + reusesMetisEndpoint: defaultEndpoint === metisEndpoint, + keepsFamiliesSeparate: metisEndpoint !== text3smallEndpoint, + }, { + defaultFamily: 'metis', + metisFamily: 'metis', + text3smallFamily: 'text-embedding-3-small', + reusesMetisEndpoint: true, + keepsFamiliesSeparate: true, + }); + }); }); class CopilotMatchableModelMetadataFetcher implements IModelMetadataFetcher { @@ -122,9 +142,9 @@ class CopilotMatchableModelMetadataFetcher implements IModelMetadataFetcher { async getChatModelFromCapiFamily(family: string): Promise { return makeChatModel(family); } - async getEmbeddingsModel(): Promise { + async getEmbeddingsModel(family: 'text-embedding-3-small' | 'metis'): Promise { return { - id: 'text-embedding-3-small', + id: family, name: 'fake-name', vendor: 'fake-vendor', version: 'fake-version', @@ -134,7 +154,7 @@ class CopilotMatchableModelMetadataFetcher implements IModelMetadataFetcher { capabilities: { type: 'embeddings', tokenizer: TokenizerType.O200K, - family: 'text-embedding-3-small', + family, limits: { max_inputs: 256 } } }; diff --git a/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts b/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts index 910d54325143..d2cc5a8fb1d3 100644 --- a/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts +++ b/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Embedding, EmbeddingType } from '../../../../platform/embeddings/common/embeddingsComputer'; +import { Embedding, EmbeddingType, EmbeddingVector } from '../../../../platform/embeddings/common/embeddingsComputer'; import { EmbeddingCacheType, IEmbeddingsCache, RemoteCacheType, RemoteEmbeddingsCache } from '../../../../platform/embeddings/common/embeddingsIndex'; import { IEnvService } from '../../../../platform/env/common/envService'; import { ILogService } from '../../../../platform/log/common/logService'; @@ -11,7 +11,11 @@ import { sanitizeVSCodeVersion } from '../../../../util/common/vscodeVersion'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { IToolEmbeddingsCache } from './toolEmbeddingsComputer'; -export const EMBEDDING_TYPE_FOR_TOOL_GROUPING = EmbeddingType.text3small_512; +export const EMBEDDING_TYPE_FOR_TOOL_GROUPING = EmbeddingType.metis_1024_I16_Binary; + +type ToolEmbeddingCacheData = + | Record + | readonly { readonly key: string; readonly embedding: EmbeddingVector }[]; export class PreComputedToolEmbeddingsCache implements IToolEmbeddingsCache { private readonly cache: IEmbeddingsCache; @@ -44,18 +48,21 @@ export class PreComputedToolEmbeddingsCache implements IToolEmbeddingsCache { private async _loadEmbeddings() { try { - const embeddingsData = await this.cache.getCache(); + const embeddingsData = await this.cache.getCache(); const embeddingsMap = new Map(); - if (embeddingsData) { - for (const [key, embeddingVector] of Object.entries(embeddingsData)) { - if (embeddingVector === undefined) { - this._logService.warn(`Tool embedding missing for key: ${key}`); - continue; - } + if (Array.isArray(embeddingsData)) { + for (const entry of embeddingsData) { + embeddingsMap.set(entry.key, { + type: this.embeddingType, + value: entry.embedding + }); + } + } else if (embeddingsData) { + for (const [key, entry] of Object.entries(embeddingsData)) { embeddingsMap.set(key, { type: this.embeddingType, - value: embeddingVector.embedding + value: entry.embedding }); } } @@ -67,5 +74,3 @@ export class PreComputedToolEmbeddingsCache implements IToolEmbeddingsCache { } } } - - diff --git a/extensions/copilot/src/extension/tools/common/virtualTools/toolEmbeddingsComputer.ts b/extensions/copilot/src/extension/tools/common/virtualTools/toolEmbeddingsComputer.ts index aa7d2e03778e..658adfd5ab7b 100644 --- a/extensions/copilot/src/extension/tools/common/virtualTools/toolEmbeddingsComputer.ts +++ b/extensions/copilot/src/extension/tools/common/virtualTools/toolEmbeddingsComputer.ts @@ -116,7 +116,7 @@ export class ToolEmbeddingsComputer implements IToolEmbeddingsComputer { return []; } - const queryEmbedding = await this._embeddingsComputer.computeEmbeddings(this._embeddingType, [query], {}, new TelemetryCorrelationId('ToolEmbeddingsComputer::searchToolsByQuery'), token); + const queryEmbedding = await this._embeddingsComputer.computeEmbeddings(this._embeddingType, [query], { inputType: 'query' }, new TelemetryCorrelationId('ToolEmbeddingsComputer::searchToolsByQuery'), token); if (!queryEmbedding || queryEmbedding.values.length === 0) { return []; } @@ -184,7 +184,7 @@ export class ToolEmbeddingsComputer implements IToolEmbeddingsComputer { const toolNames = tools.map(t => t.name + '\n\n' + t.description); const start = new StopWatch(); - const embeddings = await this._embeddingsComputer.computeEmbeddings(this._embeddingType, toolNames, {}, new TelemetryCorrelationId('ToolEmbeddingsComputer::computeEmbeddingsForTools'), token); + const embeddings = await this._embeddingsComputer.computeEmbeddings(this._embeddingType, toolNames, { inputType: 'document' }, new TelemetryCorrelationId('ToolEmbeddingsComputer::computeEmbeddingsForTools'), token); this._logService.trace(`[virtual-tools] Computed embeddings for ${toolNames.length} tools in ${start.elapsed()}ms`); if (embeddings?.values.length === 0 || embeddings?.values.length !== toolNames.length) { diff --git a/extensions/copilot/src/extension/tools/common/virtualTools/virtualToolGrouper.ts b/extensions/copilot/src/extension/tools/common/virtualTools/virtualToolGrouper.ts index 7a5de14a811f..523142464718 100644 --- a/extensions/copilot/src/extension/tools/common/virtualTools/virtualToolGrouper.ts +++ b/extensions/copilot/src/extension/tools/common/virtualTools/virtualToolGrouper.ts @@ -351,7 +351,7 @@ export class VirtualToolGrouper implements IToolCategorization { } // compute the embeddings for the query - const queryEmbedding = await this.embeddingsComputer.computeEmbeddings(EMBEDDING_TYPE_FOR_TOOL_GROUPING, [query], {}, new TelemetryCorrelationId('VirtualToolGrouper::_getPredictedTools'), token); + const queryEmbedding = await this.embeddingsComputer.computeEmbeddings(EMBEDDING_TYPE_FOR_TOOL_GROUPING, [query], { inputType: 'query' }, new TelemetryCorrelationId('VirtualToolGrouper::_getPredictedTools'), token); if (!queryEmbedding || queryEmbedding.values.length === 0) { return []; } diff --git a/extensions/copilot/src/extension/tools/test/node/virtualTools/preComputedToolEmbeddingsCache.spec.ts b/extensions/copilot/src/extension/tools/test/node/virtualTools/preComputedToolEmbeddingsCache.spec.ts new file mode 100644 index 000000000000..9afe478335f6 --- /dev/null +++ b/extensions/copilot/src/extension/tools/test/node/virtualTools/preComputedToolEmbeddingsCache.spec.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, test } from 'vitest'; +import { EmbeddingType, EmbeddingVector } from '../../../../../platform/embeddings/common/embeddingsComputer'; +import { IEmbeddingsCache } from '../../../../../platform/embeddings/common/embeddingsIndex'; +import { NullEnvService } from '../../../../../platform/env/common/nullEnvService'; +import { TestLogService } from '../../../../../platform/testing/common/testLogService'; +import { mock } from '../../../../../util/common/test/simpleMock'; +import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; +import { PreComputedToolEmbeddingsCache } from '../../../common/virtualTools/preComputedToolEmbeddingsCache'; + +function createCache(data: readonly { readonly key: string; readonly embedding: EmbeddingVector }[] | Record): PreComputedToolEmbeddingsCache { + const embeddingsCache = new class extends mock() { + override readonly embeddingType = EmbeddingType.metis_1024_I16_Binary; + + override async getCache(): Promise { + return data as T; + } + }(); + const instantiationService = new class extends mock() { }(); + instantiationService.createInstance = (() => embeddingsCache) as IInstantiationService['createInstance']; + return new PreComputedToolEmbeddingsCache(new TestLogService(), instantiationService, NullEnvService.Instance); +} + +describe('PreComputedToolEmbeddingsCache', () => { + test('loads published array entries by tool name', async () => { + const cache = createCache([{ key: 'click_element', embedding: [1, 0] }]); + + await cache.initialize(); + + expect(cache.get({ name: 'click_element' })).toEqual({ + type: EmbeddingType.metis_1024_I16_Binary, + value: [1, 0], + }); + }); + + test('loads legacy object entries by tool name', async () => { + const cache = createCache({ click_element: { embedding: [1, 0] } }); + + await cache.initialize(); + + expect(cache.get({ name: 'click_element' })).toEqual({ + type: EmbeddingType.metis_1024_I16_Binary, + value: [1, 0], + }); + }); +}); diff --git a/extensions/copilot/src/platform/embeddings/common/embeddingsIndex.ts b/extensions/copilot/src/platform/embeddings/common/embeddingsIndex.ts index 5fb2149307fb..f1067db12bb6 100644 --- a/extensions/copilot/src/platform/embeddings/common/embeddingsIndex.ts +++ b/extensions/copilot/src/platform/embeddings/common/embeddingsIndex.ts @@ -38,7 +38,7 @@ export enum RemoteCacheType { // These values are the blob storage container names where we publish computed embeddings enum RemoteEmbeddingsContainer { TEXT3SMALL = 'text-3-small', - METIS_1024_I16_BINARY = 'metis-1024-I16-Binary' + METIS_1024_I16_BINARY = 'metis-1024-i16-binary' } function embeddingsModelToRemoteContainer(embeddingType: EmbeddingType): RemoteEmbeddingsContainer { @@ -150,8 +150,8 @@ class EmbeddingsCache { await this.fileSystemService.stat(this.cacheStorageUri); await this.fileSystemService.delete(cacheFile, { useTrash: false }); } catch (e) { - if (e.code === 'ENOENT') { - throw new Error(`Cache file ${cacheFile} does not exist`); + if (e.code !== 'ENOENT' && e.code !== 'FileNotFound') { + throw e; } } } @@ -196,7 +196,7 @@ export class LocalEmbeddingsCache implements IEmbeddingsCache { this._embeddingsCache = instantiationService.createInstance( EmbeddingsCache, cacheType, - cacheKey, + `${cacheKey}-${embeddingType.id}`, cacheVersion ); } @@ -235,13 +235,14 @@ export class RemoteEmbeddingsCache implements IEmbeddingsCache { @IFetcherService protected readonly fetcherService: IFetcherService, @IInstantiationService instantiationService: IInstantiationService ) { + const modelCacheKey = `${cacheKey}-${embeddingType.id}`; this.embeddingsCache = instantiationService.createInstance( EmbeddingsCache, cacheType, - cacheKey, + modelCacheKey, cacheVersion ); - this.remoteCacheVersionKey = `${cacheKey}-version-remote`; + this.remoteCacheVersionKey = `${modelCacheKey}-version-remote`; } async clearCache(): Promise { diff --git a/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts b/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts index 8b99738731fb..a3a9a51db838 100644 --- a/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts +++ b/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts @@ -19,7 +19,7 @@ import { IEmbeddingsEndpoint, postRequest } from '../../networking/common/networ import { GenAiAttr, GenAiOperationName, GenAiProviderName } from '../../otel/common/genAiAttributes'; import { IOTelService, SpanKind, SpanStatusCode } from '../../otel/common/otelService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; -import { ComputeEmbeddingsOptions, Embedding, EmbeddingType, EmbeddingTypeInfo, EmbeddingVector, Embeddings, IEmbeddingsComputer, getWellKnownEmbeddingTypeInfo } from './embeddingsComputer'; +import { ComputeEmbeddingsOptions, Embedding, EmbeddingInputType, EmbeddingType, EmbeddingTypeInfo, EmbeddingVector, Embeddings, IEmbeddingsComputer, LEGACY_EMBEDDING_MODEL_ID, getWellKnownEmbeddingTypeInfo } from './embeddingsComputer'; interface CAPIEmbeddingResults { readonly type: 'success'; @@ -72,7 +72,7 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer { // Determine endpoint type: use CAPI for no-auth users, otherwise use GitHub const copilotToken = await this._authService.getCopilotToken(); if (copilotToken.isNoAuthUser) { - const embeddings = await this.computeCAPIEmbeddings(inputs, options, cancellationToken); + const embeddings = await this.computeCAPIEmbeddings(embeddingType, inputs, options, cancellationToken); return embeddings ?? { type: embeddingType, values: [] }; } @@ -168,19 +168,21 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer { } private async computeCAPIEmbeddings( + embeddingType: EmbeddingType, inputs: readonly string[], options?: ComputeEmbeddingsOptions, cancellationToken?: CancellationToken, ) { - const typeInfo = getWellKnownEmbeddingTypeInfo(EmbeddingType.text3small_512); + const typeInfo = getWellKnownEmbeddingTypeInfo(embeddingType); if (!typeInfo) { - throw new Error(`Embeddings type info not found: ${EmbeddingType.text3small_512}`); + throw new Error(`Embeddings type info not found: ${embeddingType}`); } - const endpoint = await this._endpointProvider.getEmbeddingsEndpoint('text3small'); + const endpointFamily = typeInfo.model === LEGACY_EMBEDDING_MODEL_ID.Metis_I16_Binary ? 'metis' : 'text3small'; + const endpoint = await this._endpointProvider.getEmbeddingsEndpoint(endpointFamily); const batchSize = endpoint.maxBatchSize; // Open AI seems to allow 1 less than max tokens for the model requests. So if the max tokens is 8192, we can only send 8191 tokens. const maxTokens = endpoint.modelMaxPromptTokens - 1; - return this.fetchResponseWithBatches(typeInfo, endpoint, inputs, cancellationToken, maxTokens, batchSize); + return this.fetchResponseWithBatches(embeddingType, typeInfo, endpoint, inputs, options?.inputType ?? 'document', cancellationToken, maxTokens, batchSize); } /** @@ -191,9 +193,11 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer { * @returns The embeddings */ private async fetchResponseWithBatches( + embeddingType: EmbeddingType, type: EmbeddingTypeInfo, endpoint: IEmbeddingsEndpoint, inputs: readonly string[], + inputType: EmbeddingInputType, cancellationToken: CancellationToken | undefined, maxTokens: number, batchSize: number, @@ -218,7 +222,7 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer { return; } - const r = await this.rawEmbeddingsFetchWithTelemetry(type, endpoint, generateUuid(), currentBatch, cancellationToken); + const r = await this.rawEmbeddingsFetchWithTelemetry(type, endpoint, generateUuid(), currentBatch, inputType, cancellationToken); if (r.type === 'failed') { throw new Error('Embeddings request failed ' + r.reason); } @@ -241,7 +245,7 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer { if (embeddings.length === 0) { return undefined; } - return { type: EmbeddingType.text3small_512, values: embeddings.map((value): Embedding => ({ type: EmbeddingType.text3small_512, value })) }; + return { type: embeddingType, values: embeddings.map((value): Embedding => ({ type: embeddingType, value })) }; } private async rawEmbeddingsFetchWithTelemetry( @@ -249,10 +253,11 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer { endpoint: IEmbeddingsEndpoint, requestId: string, inputs: readonly string[], + inputType: EmbeddingInputType, cancellationToken: CancellationToken | undefined ) { const startTime = Date.now(); - const rawRequest = await this.rawEmbeddingsFetch(type, endpoint, requestId, inputs, cancellationToken); + const rawRequest = await this.rawEmbeddingsFetch(type, endpoint, requestId, inputs, inputType, cancellationToken); if (rawRequest.type === 'failed') { this._telemetryService.sendMSFTTelemetryErrorEvent('embedding.error', { type: rawRequest.type, @@ -281,12 +286,18 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer { endpoint: IEmbeddingsEndpoint, requestId: string, inputs: readonly string[], + inputType: EmbeddingInputType, cancellationToken: CancellationToken | undefined ): Promise { try { const token = await this._authService.getCopilotToken(); - const body = { input: inputs, model: type.model, dimensions: type.dimensions }; + const body = { + input: inputs, + input_type: inputType, + model: type.model, + dimensions: type.dimensions + }; endpoint.interceptBody?.(body); const response = await this._instantiationService.invokeFunction(postRequest, { endpointOrUrl: endpoint, diff --git a/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts b/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts index be2b2ba7b4d1..61897d6a8741 100644 --- a/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts +++ b/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts @@ -108,7 +108,7 @@ abstract class RelatedInformationProviderEmbeddingsIndex this.relatedInformationConfig.threshold) { results.push(this.toRelatedInformation(item, score)); } @@ -161,13 +161,13 @@ class CommandIdIndex extends RelatedInformationProviderEmbeddingsIndex() { + override readonly hasCopilotTokenSource = true; + + override async getCopilotToken(): Promise { + return new CopilotToken(createTestExtendedTokenInfo({ sku: 'no_auth_limited_copilot' })); + } +} + +class TestTokenizer extends mock() { + override async tokenLength(): Promise { + return 1; + } +} + +class TestEmbeddingsEndpoint extends mock() { + override readonly maxBatchSize = 100; + override readonly modelMaxPromptTokens = 8192; + + override acquireTokenizer(): ITokenizer { + return new TestTokenizer(); + } +} + +class TestEndpointProvider extends mock() { + readonly requestedFamilies: Array = []; + + override async getEmbeddingsEndpoint(family?: 'text3small' | 'metis'): Promise { + this.requestedFamilies.push(family); + return new TestEmbeddingsEndpoint(); + } +} + +describe('RemoteEmbeddingsComputer', () => { + function createComputer(endpointProvider: IEndpointProvider): RemoteEmbeddingsComputer { + return new RemoteEmbeddingsComputer( + new TestAuthenticationService(), + NullEnvService.Instance, + new TestLogService(), + new NullTelemetryService(), + endpointProvider, + new class extends mock() { }(), + new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '1.0.0', sessionId: 'test' })), + ); + } + + test('uses the Metis CAPI endpoint for Metis embeddings', async () => { + const endpointProvider = new TestEndpointProvider(); + const computer = createComputer(endpointProvider); + const fetch = vi.spyOn(computer, 'rawEmbeddingsFetch').mockResolvedValue({ + type: 'success', + embeddings: [[1, 0]], + }); + + const result = await computer.computeEmbeddings(EmbeddingType.metis_1024_I16_Binary, ['input'], { inputType: 'query' }); + + expect({ + requestedFamilies: endpointProvider.requestedFamilies, + requestedModel: fetch.mock.calls[0][0].model, + inputType: fetch.mock.calls[0][4], + resultType: result.type.id, + valueTypes: result.values.map(value => value.type.id), + }).toEqual({ + requestedFamilies: ['metis'], + requestedModel: LEGACY_EMBEDDING_MODEL_ID.Metis_I16_Binary, + inputType: 'query', + resultType: EmbeddingType.metis_1024_I16_Binary.id, + valueTypes: [EmbeddingType.metis_1024_I16_Binary.id], + }); + }); + + test('preserves the text3small endpoint for text3small embeddings', async () => { + const endpointProvider = new TestEndpointProvider(); + const computer = createComputer(endpointProvider); + const fetch = vi.spyOn(computer, 'rawEmbeddingsFetch').mockResolvedValue({ + type: 'success', + embeddings: [[1, 0]], + }); + + const result = await computer.computeEmbeddings(EmbeddingType.text3small_512, ['input']); + + expect({ + requestedFamilies: endpointProvider.requestedFamilies, + requestedModel: fetch.mock.calls[0][0].model, + inputType: fetch.mock.calls[0][4], + resultType: result.type.id, + }).toEqual({ + requestedFamilies: ['text3small'], + requestedModel: LEGACY_EMBEDDING_MODEL_ID.TEXT3SMALL, + inputType: 'document', + resultType: EmbeddingType.text3small_512.id, + }); + }); +}); diff --git a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts index 601295b10a80..dce052e7f483 100644 --- a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts +++ b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts @@ -22,6 +22,8 @@ import { IExperimentationService } from '../../telemetry/common/nullExperimentat import { getModelCapabilityOverride } from '../common/chatModelCapabilities'; import { IChatModelInformation, ICompletionModelInformation, IEmbeddingModelInformation, IModelAPIResponse, isChatModelInformation, isCompletionModelInformation, isEmbeddingModelInformation } from '../common/endpointProvider'; +export type EmbeddingsModelFamily = 'text-embedding-3-small' | 'metis'; + export interface IModelMetadataFetcher { /** @@ -67,7 +69,7 @@ export interface IModelMetadataFetcher { * Retrieves an embeddings model by its family name * @param family The family of the model to fetch */ - getEmbeddingsModel(family: 'text-embedding-3-small'): Promise; + getEmbeddingsModel(family: EmbeddingsModelFamily): Promise; } /** @@ -219,7 +221,7 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe return resolvedModel; } - public async getEmbeddingsModel(family: 'text-embedding-3-small'): Promise { + public async getEmbeddingsModel(family: EmbeddingsModelFamily): Promise { await this._taskSingler.getOrCreate(ModelMetadataFetcher.ALL_MODEL_KEY, this._fetchModels.bind(this)); const resolvedModel = this._familyMap.get(family)?.[0]; if (!resolvedModel || !isEmbeddingModelInformation(resolvedModel)) { diff --git a/extensions/copilot/src/platform/projectTemplatesIndex/common/projectTemplatesIndex.ts b/extensions/copilot/src/platform/projectTemplatesIndex/common/projectTemplatesIndex.ts index a557fa7071ce..54692f616938 100644 --- a/extensions/copilot/src/platform/projectTemplatesIndex/common/projectTemplatesIndex.ts +++ b/extensions/copilot/src/platform/projectTemplatesIndex/common/projectTemplatesIndex.ts @@ -37,8 +37,8 @@ export class ProjectTemplatesIndex implements IProjectTemplatesIndex { ) { const cacheVersion = sanitizeVSCodeVersion(envService.getEditorInfo().version); this.embeddingsCache = useRemoteCache ? - instantiationService.createInstance(RemoteEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'projectTemplateEmbeddings', cacheVersion, EmbeddingType.text3small_512, RemoteCacheType.ProjectTemplates) - : instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'projectTemplateEmbeddings', cacheVersion, EmbeddingType.text3small_512); + instantiationService.createInstance(RemoteEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'projectTemplateEmbeddings', cacheVersion, EmbeddingType.metis_1024_I16_Binary, RemoteCacheType.ProjectTemplates) + : instantiationService.createInstance(LocalEmbeddingsCache, EmbeddingCacheType.GLOBAL, 'projectTemplateEmbeddings', cacheVersion, EmbeddingType.metis_1024_I16_Binary); } async updateIndex(): Promise { From 24a41178148f72f49e4ac0756ddb3b5347429a91 Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Sun, 4 Oct 2026 17:24:44 +0200 Subject: [PATCH 12/49] sessions: Reopen dismissed canvases from Add Tab (#339574) * sessions: reopen dismissed canvases from Add Tab Expose live dismissed canvas instances in the editor Add Tab menu, using provider titles and semantic instance IDs to disambiguate duplicates. Keep registrations scoped to the active conversation and clean them up reactively. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: stabilize canvas reopen lifecycle Keep Add Tab command identities stable across canvas metadata updates, restore retry state when editor opens fail across chat switches, and guarantee unique instance labels. Cover stale menu actions, canvas lifetime removal, automatic-open failures, and label collisions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/canvases/common/sessionCanvas.ts | 13 +- .../electron-browser/sessionCanvasActions.ts | 134 ++++++++++++ .../electron-browser/sessionCanvasEditor.ts | 2 +- .../electron-browser/sessionCanvasService.ts | 76 +++++-- .../sessionCanvases.contribution.ts | 4 +- .../sessionCanvasService.test.ts | 191 +++++++++++++++++- 6 files changed, 398 insertions(+), 22 deletions(-) create mode 100644 src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasActions.ts diff --git a/src/vs/sessions/contrib/canvases/common/sessionCanvas.ts b/src/vs/sessions/contrib/canvases/common/sessionCanvas.ts index 01eee9e355ab..d298f5ce28db 100644 --- a/src/vs/sessions/contrib/canvases/common/sessionCanvas.ts +++ b/src/vs/sessions/contrib/canvases/common/sessionCanvas.ts @@ -21,19 +21,30 @@ export interface ISessionCanvasReference { readonly canvas: URI; } +export function getSessionCanvasReferenceKey(reference: ISessionCanvasReference): string { + return `${reference.providerId}\u0000${reference.session.toString()}\u0000${reference.chat.toString()}\u0000${reference.canvas.toString()}`; +} + export interface ISessionCanvasTarget { readonly session: ISession; readonly chat: IChat; readonly canvas: ISessionCanvas; } +export interface ISessionCanvasReopenTarget { + readonly reference: ISessionCanvasReference; + readonly canvas: ISessionCanvas; +} + export const ISessionCanvasService = createDecorator('sessionCanvasService'); export interface ISessionCanvasService { readonly _serviceBrand: undefined; readonly enabled: IObservable; + readonly reopenableCanvases: IObservable; getTarget(reference: ISessionCanvasReference, reader?: IReader): ISessionCanvasTarget | undefined; isActiveOwner(reference: ISessionCanvasReference, reader?: IReader): boolean; + reopenCanvas(reference: ISessionCanvasReference): Promise; } function createInputResource(reference: ISessionCanvasReference): URI { @@ -67,7 +78,7 @@ export class SessionCanvasInput extends EditorInput { override get editorId(): string { return SessionCanvasInput.EDITOR_ID; } override get capabilities(): EditorInputCapabilities { return EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton | EditorInputCapabilities.ForceReveal; } override getName(): string { return this.canvas.get()?.title ?? localize('canvas.editorName', "Canvas"); } - override getDescription(): string { return localize('canvas.editorDescription', "Closing this tab hides the canvas until the agent opens it again."); } + override getDescription(): string { return localize('canvas.editorDescription', "Closing this tab hides the canvas. Reopen it from the Add Tab menu while it remains available."); } override getIcon(): ThemeIcon { return Codicon.preview; } override getTitle(_verbosity?: Verbosity): string { return this.getName(); } override canReopen(): boolean { return false; } diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasActions.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasActions.ts new file mode 100644 index 000000000000..135b7d688cf6 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasActions.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext } from '../../../../workbench/common/contextkeys.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { Menus } from '../../../browser/menus.js'; +import { getSessionCanvasReferenceKey, ISessionCanvasReference, ISessionCanvasReopenTarget, ISessionCanvasService } from '../common/sessionCanvas.js'; + +export const REOPEN_SESSION_CANVAS_COMMAND_ID = 'workbench.action.agentSessions.reopenCanvas'; + +const reopenCanvasWhen = ContextKeyExpr.and( + ChatContextKeys.enabled, + IsSessionsWindowContext, + IsAuxiliaryWindowContext.toNegated(), + IsTopRightEditorGroupContext, +); + +class CanvasAddTabActionRegistration extends Disposable { + + private readonly menuRegistration = this._register(new MutableDisposable()); + private title: string | undefined; + private order: number | undefined; + + constructor( + readonly commandId: string, + reference: ISessionCanvasReference, + canvasService: ISessionCanvasService, + ) { + super(); + this._register(CommandsRegistry.registerCommand(commandId, () => canvasService.reopenCanvas(reference))); + } + + update(title: string, order: number): void { + if (this.title === title && this.order === order) { + return; + } + this.title = title; + this.order = order; + this.menuRegistration.value = MenuRegistry.appendMenuItem(Menus.SessionsEditorTabsBarAddTab, { + command: { + id: this.commandId, + title, + icon: Codicon.preview, + }, + group: 'navigation', + order, + when: reopenCanvasWhen, + }); + } +} + +export function registerSessionCanvasAddTabActions(canvasService: ISessionCanvasService): IDisposable { + const store = new DisposableStore(); + const registrations = store.add(new DisposableMap()); + let commandSequence = 0; + store.add(autorun(reader => { + const targets = canvasService.reopenableCanvases.read(reader); + const labels = getCanvasInstanceLabels(targets); + const activeKeys = new Set(); + for (let index = 0; index < targets.length; index++) { + const target = targets[index]; + const key = getSessionCanvasReferenceKey(target.reference); + activeKeys.add(key); + let registration = registrations.get(key); + if (!registration) { + registration = new CanvasAddTabActionRegistration( + `${REOPEN_SESSION_CANVAS_COMMAND_ID}.${++commandSequence}`, + target.reference, + canvasService, + ); + registrations.set(key, registration); + } + registration.update(labels[index], 4 + index); + } + for (const key of [...registrations.keys()]) { + if (!activeKeys.has(key)) { + registrations.deleteAndDispose(key); + } + } + })); + return store; +} + +function getCanvasInstanceLabels(targets: readonly ISessionCanvasReopenTarget[]): string[] { + const titleCounts = new Map(); + const instanceIdCounts = new Map(); + for (const { canvas } of targets) { + titleCounts.set(canvas.title, (titleCounts.get(canvas.title) ?? 0) + 1); + if (canvas.instanceId) { + instanceIdCounts.set(canvas.instanceId, (instanceIdCounts.get(canvas.instanceId) ?? 0) + 1); + } + } + + const labels = new Array(targets.length); + const usedLabels = new Set(); + for (let index = 0; index < targets.length; index++) { + const { canvas } = targets[index]; + if (titleCounts.get(canvas.title) === 1) { + labels[index] = canvas.title; + usedLabels.add(canvas.title); + } + } + + const titleIndexes = new Map(); + for (let index = 0; index < targets.length; index++) { + const { canvas } = targets[index]; + if (titleCounts.get(canvas.title) === 1) { + continue; + } + + const titleIndex = (titleIndexes.get(canvas.title) ?? 0) + 1; + titleIndexes.set(canvas.title, titleIndex); + const instanceLabel = canvas.instanceId && instanceIdCounts.get(canvas.instanceId) === 1 + ? canvas.instanceId + : String(titleIndex); + let label = localize('canvas.instanceTitle', "{0} ({1})", canvas.title, instanceLabel); + let collisionIndex = 2; + while (usedLabels.has(label)) { + label = localize('canvas.instanceTitleCollision', "{0} ({1}, {2})", canvas.title, instanceLabel, String(collisionIndex++)); + } + labels[index] = label; + usedLabels.add(label); + } + return labels; +} diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts index 8a3671294f03..33b093095e24 100644 --- a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts @@ -295,7 +295,7 @@ export class SessionCanvasEditor extends EditorPane { const help = [ localize('canvas.help.overview', "This canvas is a private page owned by the active conversation."), localize('canvas.help.navigation', "Tab moves through page controls. Use to leave the page."), - localize('canvas.help.close', "Closing the tab hides this canvas. A newly created canvas can open another tab."), + localize('canvas.help.close', "Closing the tab hides this canvas. Use the Add Tab menu in the editor toolbar and choose its title to reopen it while it remains available."), ].join('\n\n'); this.semanticText = localize('canvas.reading', "Reading accessible canvas content…"); return new AccessibleContentProvider( diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts index 06cf625f862a..e1d55d91dc96 100644 --- a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts @@ -5,7 +5,7 @@ import { Event } from '../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorun, IReader, observableFromEvent } from '../../../../base/common/observable.js'; +import { autorun, derived, IReader, observableFromEvent, observableSignal } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { CanvasesEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -15,18 +15,20 @@ import { IEditorService } from '../../../../workbench/services/editor/common/edi import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IChat, ISession, ISessionCanvas } from '../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISessionCanvasReference, ISessionCanvasService, ISessionCanvasTarget, SessionCanvasInput } from '../common/sessionCanvas.js'; +import { getSessionCanvasReferenceKey, ISessionCanvasReference, ISessionCanvasReopenTarget, ISessionCanvasService, ISessionCanvasTarget, SessionCanvasInput } from '../common/sessionCanvas.js'; export class SessionCanvasService extends Disposable implements ISessionCanvasService { declare readonly _serviceBrand: undefined; readonly enabled; + readonly reopenableCanvases; private readonly _inputs = this._register(new DisposableMap()); private readonly _inputLifetimes = this._register(new DisposableMap()); private readonly _dismissed = new Map(); private readonly _presented = new Set(); private readonly _programmaticCloses = new Set(); + private readonly _dismissedChanged = observableSignal(this); constructor( @ISessionsService private readonly sessionsService: ISessionsService, @@ -45,6 +47,21 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe !entitlementService.sentiment.hidden && configurationService.getValue(CanvasesEnabledSettingId) === true ); + this.reopenableCanvases = derived(this, reader => { + this._dismissedChanged.read(reader); + if (!this.enabled.read(reader)) { + return []; + } + + const reopenable: ISessionCanvasReopenTarget[] = []; + for (const reference of this._dismissed.values()) { + const target = this.getTarget(reference, reader); + if (target?.canvas.source !== undefined) { + reopenable.push({ reference, canvas: target.canvas }); + } + } + return reopenable; + }); this._register(sessionsManagementService.onDidChangeSessions(event => { for (const session of event.removed) { this._removeSession(session); @@ -67,14 +84,13 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe canvas: canvas.resource, }; const input = this._getOrCreateInput(reference, canvas); - const key = canvasKey(reference); + const key = getSessionCanvasReferenceKey(reference); activeKeys.add(key); input.setCanvas(canvas); if (canvas.source === undefined || this._dismissed.has(key) || this._presented.has(key)) { continue; } - this._presented.add(key); - void this.editorService.openEditor(input, { pinned: true, revealIfOpened: true, preserveFocus: false }) + void this._openInput(key, input) .catch(error => this.logService.error('[SessionCanvasService] Failed to reveal canvas', error)); } } @@ -82,7 +98,7 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe if (activeSession && activeChat && canvases !== undefined) { for (const [key, dismissed] of this._dismissed) { if (ownsChat(dismissed, activeSession, activeChat) && !activeKeys.has(key)) { - this._dismissed.delete(key); + this._deleteDismissed(key); } } } @@ -114,8 +130,34 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe return this.enabled.read(reader) && this.getTarget(reference, reader) !== undefined; } + async reopenCanvas(reference: ISessionCanvasReference): Promise { + const key = getSessionCanvasReferenceKey(reference); + const reopenable = this.reopenableCanvases.get().find(candidate => getSessionCanvasReferenceKey(candidate.reference) === key); + if (!reopenable) { + return; + } + + const input = this._getOrCreateInput(reference, reopenable.canvas); + input.setCanvas(reopenable.canvas); + this._deleteDismissed(key); + await this._openInput(key, input); + } + + private async _openInput(key: string, input: SessionCanvasInput): Promise { + this._presented.add(key); + try { + await this.editorService.openEditor(input, { pinned: true, revealIfOpened: true, preserveFocus: false }); + } catch (error) { + this._presented.delete(key); + if (this.enabled.get() && this._inputs.get(key) === input && !input.isDisposed()) { + this._rememberDismissed(key, input.reference); + } + throw error; + } + } + private _getOrCreateInput(reference: ISessionCanvasReference, canvas: ISessionCanvas): SessionCanvasInput { - const key = canvasKey(reference); + const key = getSessionCanvasReferenceKey(reference); let input = this._inputs.get(key); if (input && !input.isDisposed()) { return input; @@ -126,7 +168,7 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe this._inputLifetimes.set(key, lifetime); lifetime.add(Event.once(input.onWillDispose)(() => { if (!this._programmaticCloses.delete(key)) { - this._dismissed.set(key, reference); + this._rememberDismissed(key, reference); } if (this._inputs.get(key) === input) { this._inputs.deleteAndLeak(key); @@ -143,7 +185,7 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe private _removeSession(session: ISession): void { for (const [key, dismissed] of this._dismissed) { if (ownsSession(dismissed, session)) { - this._dismissed.delete(key); + this._deleteDismissed(key); } } for (const [key, input] of this._inputs) { @@ -165,7 +207,7 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe if (lifetime && this._inputLifetimes.get(key) === lifetime) { this._inputLifetimes.deleteAndLeak(key); } - this._dismissed.delete(key); + this._deleteDismissed(key); this._presented.delete(key); await this.editorService.closeEditors(this.editorService.findEditors(input.resource), { preserveFocus: true }); if (!input.isDisposed()) { @@ -174,10 +216,18 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe lifetime?.dispose(); this._programmaticCloses.delete(key); } -} -function canvasKey(reference: ISessionCanvasReference): string { - return `${reference.providerId}\u0000${reference.session.toString()}\u0000${reference.chat.toString()}\u0000${reference.canvas.toString()}`; + private _rememberDismissed(key: string, reference: ISessionCanvasReference): void { + this._dismissed.delete(key); + this._dismissed.set(key, reference); + this._dismissedChanged.trigger(undefined); + } + + private _deleteDismissed(key: string): void { + if (this._dismissed.delete(key)) { + this._dismissedChanged.trigger(undefined); + } + } } function ownsSession(reference: ISessionCanvasReference, session: ISession): boolean { diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvases.contribution.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvases.contribution.ts index 8a3992bfcdf6..2648d7bb1d5e 100644 --- a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvases.contribution.ts +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvases.contribution.ts @@ -17,6 +17,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { ISessionCanvasService, SessionCanvasInput } from '../common/sessionCanvas.js'; +import { registerSessionCanvasAddTabActions } from './sessionCanvasActions.js'; import { SessionCanvasEditor, SessionCanvasFocusedContext } from './sessionCanvasEditor.js'; import { SessionCanvasService } from './sessionCanvasService.js'; @@ -32,9 +33,10 @@ class SessionCanvasesContribution extends Disposable implements IWorkbenchContri static readonly ID = 'sessions.contrib.canvases'; constructor( - @ISessionCanvasService _canvasService: ISessionCanvasService, + @ISessionCanvasService canvasService: ISessionCanvasService, ) { super(); + this._register(registerSessionCanvasAddTabActions(canvasService)); for (const type of [AccessibleViewType.Help, AccessibleViewType.View]) { this._register(AccessibleViewRegistry.register({ type, diff --git a/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts b/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts index 83b78a8dae47..6cc3d32c681a 100644 --- a/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts +++ b/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts @@ -4,27 +4,35 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { CanvasesEnabledSettingId } from '../../../../../platform/agentHost/common/agentService.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationChangeEvent } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext } from '../../../../../workbench/common/contextkeys.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { Menus } from '../../../../browser/menus.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IChat, ISessionCanvas, ISessionCapabilities } from '../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { SessionCanvasInput } from '../../common/sessionCanvas.js'; +import { registerSessionCanvasAddTabActions, REOPEN_SESSION_CANVAS_COMMAND_ID } from '../../electron-browser/sessionCanvasActions.js'; import { SessionCanvasService } from '../../electron-browser/sessionCanvasService.js'; suite('SessionCanvasService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createHarness(canvasesEnabled = true) { + function createHarness(canvasesEnabled = true, initialCanvases?: readonly ISessionCanvas[]) { const sessionResource = URI.parse('agent-host-session:/session'); const chatResource = URI.parse('agent-host-chat:/session/main'); const canvas: ISessionCanvas = { @@ -33,7 +41,7 @@ suite('SessionCanvasService', () => { title: 'Preview', source: URI.parse('https://example.test/preview'), }; - const canvases = observableValue('canvases', [canvas]); + const canvases = observableValue('canvases', initialCanvases ?? [canvas]); const chat = upcastPartial({ resource: chatResource, canvases }); const activeChat = observableValue('activeChat', chat); const capabilities = observableValue('capabilities', { supportsCanvases: true, supportsMultipleChats: false }); @@ -49,12 +57,16 @@ suite('SessionCanvasService', () => { const sessionsManagementService = upcastPartial({ onDidChangeSessions: sessionChanges.event }); const opened: SessionCanvasInput[] = []; const openOptions: unknown[] = []; + const openSettled: Promise[] = []; + let openEditorHandler = () => Promise.resolve(undefined); let closeCount = 0; const editorService = new class extends mock() { - override async openEditor(...args: unknown[]): Promise { + override openEditor(...args: unknown[]): Promise { opened.push(args[0] as SessionCanvasInput); openOptions.push(args[1]); - return undefined; + const result = openEditorHandler(); + openSettled.push(result.then(() => undefined, () => undefined)); + return result; } override findEditors(): never[] { return []; @@ -68,7 +80,7 @@ suite('SessionCanvasService', () => { onDidChangeSentiment: Event.None, }); const configurationService = new TestConfigurationService({ [CanvasesEnabledSettingId]: canvasesEnabled }); - store.add(new SessionCanvasService( + const canvasService = store.add(new SessionCanvasService( sessionsService, sessionsManagementService, editorService, @@ -82,7 +94,8 @@ suite('SessionCanvasService', () => { affectsConfiguration: key => key === CanvasesEnabledSettingId, })); }; - return { activeSession, canvas, canvases, opened, openOptions, session, sessionChanges, get closeCount() { return closeCount; }, setCanvasesEnabled }; + const setOpenEditorHandler = (handler: () => Promise) => openEditorHandler = handler; + return { activeChat, activeSession, canvas, canvasService, canvases, chat, opened, openOptions, openSettled, session, sessionChanges, get closeCount() { return closeCount; }, setCanvasesEnabled, setOpenEditorHandler }; } test('automatically reveals a newly opened canvas', () => { @@ -105,6 +118,172 @@ suite('SessionCanvasService', () => { assert.deepStrictEqual({ opened: harness.opened.length, closed: harness.closeCount }, { opened: 1, closed: 1 }); }); + test('reopens a dismissed canvas from the Add Tab action state', async () => { + const { canvasService, opened, openOptions } = createHarness(); + const original = opened[0]; + const reopenableInitially = canvasService.reopenableCanvases.get(); + + original.dispose(); + const reopenableAfterDismiss = canvasService.reopenableCanvases.get(); + await canvasService.reopenCanvas(reopenableAfterDismiss[0].reference); + + assert.deepStrictEqual({ + reopenableInitially: reopenableInitially.length, + reopenableAfterDismiss: reopenableAfterDismiss.map(target => ({ + title: target.canvas.title, + instanceId: target.canvas.instanceId, + })), + reopenableAfterOpen: canvasService.reopenableCanvases.get().length, + openCount: opened.length, + reopenedResource: opened[1].reference.canvas.toString(), + reopenedWithNewInput: opened[1] !== original, + openOptions: openOptions[1], + }, { + reopenableInitially: 0, + reopenableAfterDismiss: [{ title: 'Preview', instanceId: 'preview' }], + reopenableAfterOpen: 0, + openCount: 2, + reopenedResource: 'agent-host-canvas:/preview', + reopenedWithNewInput: true, + openOptions: { pinned: true, revealIfOpened: true, preserveFocus: false }, + }); + }); + + test('restores dismissal when reopening fails after switching chats', async () => { + const { activeChat, canvasService, chat, opened, setOpenEditorHandler } = createHarness(); + opened[0].dispose(); + const reopenable = canvasService.reopenableCanvases.get()[0]; + const failedOpen = new DeferredPromise(); + setOpenEditorHandler(() => failedOpen.p); + + const reopenRejected = assert.rejects(canvasService.reopenCanvas(reopenable.reference), /open failed/); + activeChat.set(upcastPartial({ + resource: URI.parse('agent-host-chat:/session/other'), + canvases: observableValue('otherCanvases', []), + }), undefined); + await failedOpen.error(new Error('open failed')); + await reopenRejected; + activeChat.set(chat, undefined); + + assert.deepStrictEqual({ + openCount: opened.length, + reopenable: canvasService.reopenableCanvases.get().map(target => target.canvas.resource.toString()), + }, { + openCount: 2, + reopenable: ['agent-host-canvas:/preview'], + }); + }); + + test('does not restore dismissal when the canvas disappears during a failed reopen', async () => { + const { canvas, canvasService, canvases, opened, setOpenEditorHandler } = createHarness(); + opened[0].dispose(); + const reopenable = canvasService.reopenableCanvases.get()[0]; + const failedOpen = new DeferredPromise(); + setOpenEditorHandler(() => failedOpen.p); + + const reopenRejected = assert.rejects(canvasService.reopenCanvas(reopenable.reference), /open failed/); + canvases.set([], undefined); + await failedOpen.error(new Error('open failed')); + await reopenRejected; + setOpenEditorHandler(() => Promise.resolve(undefined)); + canvases.set([canvas], undefined); + + assert.deepStrictEqual({ + openCount: opened.length, + reopenable: canvasService.reopenableCanvases.get().length, + }, { + openCount: 3, + reopenable: 0, + }); + }); + + test('offers an automatically failed canvas reveal for manual reopening', async () => { + const { canvas, canvasService, canvases, opened, openSettled, setOpenEditorHandler } = createHarness(); + const failedCanvas: ISessionCanvas = { + resource: URI.parse('agent-host-canvas:/failed'), + instanceId: 'failed', + title: 'Failed Preview', + source: URI.parse('https://example.test/failed'), + }; + const failedOpen = new DeferredPromise(); + setOpenEditorHandler(() => failedOpen.p); + + canvases.set([canvas, failedCanvas], undefined); + await failedOpen.error(new Error('automatic open failed')); + await openSettled.at(-1); + await Promise.resolve(); + canvases.set([canvas, { ...failedCanvas, status: 'retry' }], undefined); + + assert.deepStrictEqual({ + openCount: opened.length, + reopenable: canvasService.reopenableCanvases.get().map(target => target.canvas.resource.toString()), + }, { + openCount: 2, + reopenable: ['agent-host-canvas:/failed'], + }); + }); + + test('contributes titled canvas instances to the right pane Add Tab menu', async () => { + const canvases: ISessionCanvas[] = [ + { resource: URI.parse('agent-host-canvas:/preview-editor'), instanceId: 'editor', title: 'Preview', source: URI.parse('https://example.test/editor') }, + { resource: URI.parse('agent-host-canvas:/preview-sidebar'), instanceId: 'sidebar', title: 'Preview', source: URI.parse('https://example.test/sidebar') }, + { resource: URI.parse('agent-host-canvas:/preview-editor-title'), instanceId: 'dashboard', title: 'Preview (editor)', source: URI.parse('https://example.test/editor-title') }, + { resource: URI.parse('agent-host-canvas:/dashboard'), instanceId: 'dashboard', title: 'Dashboard', source: URI.parse('https://example.test/dashboard') }, + { resource: URI.parse('agent-host-canvas:/logs-numbered'), instanceId: undefined, title: 'Logs', source: URI.parse('https://example.test/logs-numbered') }, + { resource: URI.parse('agent-host-canvas:/logs-semantic'), instanceId: '1', title: 'Logs', source: URI.parse('https://example.test/logs-semantic') }, + ]; + const { canvasService, canvases: canvasStates, opened } = createHarness(true, canvases); + const registration = store.add(registerSessionCanvasAddTabActions(canvasService)); + for (const input of [...opened]) { + input.dispose(); + } + const getItems = () => MenuRegistry.getMenuItems(Menus.SessionsEditorTabsBarAddTab) + .filter(isIMenuItem) + .filter(item => item.command.id.startsWith(`${REOPEN_SESSION_CANVAS_COMMAND_ID}.`)) + .sort((first, second) => (first.order ?? 0) - (second.order ?? 0)); + const items = getItems(); + const initialCommandIds = items.map(item => item.command.id); + const when = items[0].when?.serialize() ?? ''; + canvasStates.set(canvases.map((canvas, index) => index === 1 ? { ...canvas, title: 'Preview Updated' } : canvas), undefined); + const refreshedItems = getItems(); + await CommandsRegistry.getCommand(items[1].command.id)!.handler(upcastPartial({})); + const remainingItems = getItems(); + const remainingTitles = remainingItems.map(item => typeof item.command.title === 'string' ? item.command.title : item.command.title.value); + const remainingCommandIds = remainingItems.map(item => item.command.id); + const staleCommandsRegistered = initialCommandIds.map(id => CommandsRegistry.getCommand(id) !== undefined); + registration.dispose(); + + assert.deepStrictEqual({ + titles: items.map(item => typeof item.command.title === 'string' ? item.command.title : item.command.title.value), + groups: items.map(item => item.group), + requiresChat: when.includes(ChatContextKeys.enabled.key), + requiresSessionsWindow: when.includes(IsSessionsWindowContext.key), + excludesAuxiliaryWindow: when.includes(`!${IsAuxiliaryWindowContext.key}`), + requiresRightPane: when.includes(IsTopRightEditorGroupContext.key), + reopenedResource: opened.at(-1)?.reference.canvas.toString(), + refreshedCommandIds: refreshedItems.map(item => item.command.id), + refreshedTitles: refreshedItems.map(item => typeof item.command.title === 'string' ? item.command.title : item.command.title.value), + remainingTitles, + initialCommandsRegisteredAfterReopen: staleCommandsRegistered, + itemsAfterDispose: getItems().length, + remainingCommandsAfterDispose: remainingCommandIds.map(id => CommandsRegistry.getCommand(id) !== undefined), + }, { + titles: ['Preview (editor, 2)', 'Preview (sidebar)', 'Preview (editor)', 'Dashboard', 'Logs (1)', 'Logs (1, 2)'], + groups: ['navigation', 'navigation', 'navigation', 'navigation', 'navigation', 'navigation'], + requiresChat: true, + requiresSessionsWindow: true, + excludesAuxiliaryWindow: true, + requiresRightPane: true, + reopenedResource: 'agent-host-canvas:/preview-sidebar', + refreshedCommandIds: initialCommandIds, + refreshedTitles: ['Preview', 'Preview Updated', 'Preview (editor)', 'Dashboard', 'Logs (1)', 'Logs (1, 2)'], + remainingTitles: ['Preview', 'Preview (editor)', 'Dashboard', 'Logs (1)', 'Logs (1, 2)'], + initialCommandsRegisteredAfterReopen: [true, false, true, true, true, true], + itemsAfterDispose: 0, + remainingCommandsAfterDispose: [false, false, false, false, false], + }); + }); + test('forgets a dismissed canvas when the provider removes its membership', () => { const { canvas, canvases, opened } = createHarness(); opened[0].dispose(); From b993b3bce18ed4816ff7076e7c33d0fbf3e01cda Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:36:43 -0700 Subject: [PATCH 13/49] chat: add Blobby color customization and True Name achievement (#339561) * chat: add Blobby color customization Add the True Name achievement and /blobby flow, with free Stable and Insiders palettes plus unlockable preset and custom colors. Unify achievements and color selection in the customization editor while preserving sprite animations, accessories, accessibility, and persisted choices. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: update Blobby customization screenshot baselines Accept the reviewed Linux CI screenshots for the new customization tabs and True Name account badge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: hide Blobby commands when AI features are disabled Gate both slash command descriptors and the new customization actions on chatSetupHidden, independently of whether a default agent is registered. Add regression coverage for hidden and restored availability while retaining the runtime entitlement guards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/account.contribution.ts | 13 +- .../browser/chatPetAchievementBadges.ts | 54 +- .../test/browser/account.contribution.test.ts | 31 +- .../browser/sessionsChatAccessibilityHelp.ts | 1 + .../contrib/chat/browser/slashCommands.ts | 16 + .../chat/test/browser/slashCommands.test.ts | 36 +- .../browser/actions/chatAccessibilityHelp.ts | 3 +- .../chat/browser/chatPetAchievementPreview.ts | 23 +- .../chatPetAchievements.contribution.ts | 61 +- .../chat/browser/chatPetAchievements.ts | 25 +- .../chat/browser/chatPetAchievementsEditor.ts | 121 +++- .../browser/chatPetAchievementsEditorInput.ts | 10 +- .../chat/browser/chatPetAchievementsWidget.ts | 73 +- .../contrib/chat/browser/chatPetColors.ts | 144 ++++ .../chat/browser/chatPetColorsWidget.ts | 212 ++++++ .../contrib/chat/browser/chatPetService.ts | 65 +- .../contrib/chat/browser/chatSlashCommands.ts | 13 + .../browser/media/chatPetAchievements.css | 233 +++++- .../widget/chatPetAccessoryRenderer.ts | 13 +- .../chat/browser/widget/chatPetWidget.ts | 79 +- .../chat/browser/widget/media/chatPet.css | 5 +- .../chatAccessibilityHelp.test.ts | 6 +- .../browser/chatPetAchievementsEditor.test.ts | 179 ++++- .../chat/test/browser/chatPetColors.test.ts | 680 ++++++++++++++++++ .../test/browser/widget/chatPetWidget.test.ts | 117 ++- .../chat/chatFixtureUtils.ts | 4 +- .../chat/chatPetAccessoryRig.fixture.ts | 44 +- .../chat/chatPetAchievementsEditor.fixture.ts | 53 +- .../chat/chatPetFixtureUtils.ts | 14 +- .../blocks-ci-screenshots.md | 4 +- 30 files changed, 2159 insertions(+), 173 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/chatPetColors.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatPetColorsWidget.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 9504c788036e..87799d6e0f21 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -61,6 +61,7 @@ import { CHAT_SETUP_ACTION_ID } from '../../../../workbench/contrib/chat/browser import { AGENTIC_SIGN_IN_COMMAND_ID } from '../../../common/sessionCommands.js'; import { SessionsChatPetAchievementBadges } from './chatPetAchievementBadges.js'; import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { CHAT_PET_CHANGE_COLOR_COMMAND_ID } from '../../../../workbench/contrib/chat/browser/chatPetColors.js'; // --- Account Menu Items --- // const AccountMenu = Menus.AccountMenu; @@ -736,11 +737,13 @@ export class TitleBarAccountWidget extends BaseActionViewItem { } } - panelStore.add(this.instantiationService.createInstance(SessionsChatPetAchievementBadges, panel, () => { - this.hoverService.hideHover(true); - this.clickPanelDisposable.clear(); - void this.commandService.executeCommand(CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID); - })); + if (!this.chatEntitlementService.sentiment.hidden) { + panelStore.add(this.instantiationService.createInstance(SessionsChatPetAchievementBadges, panel, tab => { + this.hoverService.hideHover(true); + this.clickPanelDisposable.clear(); + void this.commandService.executeCommand(tab === 'color' ? CHAT_PET_CHANGE_COLOR_COMMAND_ID : CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID); + })); + } if (this.shouldShowCopilotDashboardHover()) { const footer = append(panel, $('section.sessions-account-titlebar-panel-footer', { diff --git a/src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts b/src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts index bae9e834fdd2..648a25c2f132 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts @@ -16,8 +16,10 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, renderChatPetAchievementPreview } from '../../../../workbench/contrib/chat/browser/chatPetAchievementPreview.js'; -import { chatPetAchievements, ChatPetAccessoryId, ChatPetAchievementId, IChatPetAchievement } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; -import { ChatPetVariant, IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { chatPetAchievements, ChatPetAccessoryId, ChatPetAchievementId, getChatPetAchievementRewardLabels, IChatPetAchievement } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { ChatPetColor } from '../../../../workbench/contrib/chat/browser/chatPetColors.js'; +import { ChatPetCustomizationTab } from '../../../../workbench/contrib/chat/browser/chatPetAchievementsEditorInput.js'; export interface ISessionsChatPetAchievementBadge { readonly achievement: IChatPetAchievement; @@ -43,7 +45,7 @@ export class SessionsChatPetAchievementBadges extends Disposable { constructor( parent: HTMLElement, - private readonly onOpenAchievements: () => void, + private readonly onOpenCustomization: (tab: ChatPetCustomizationTab) => void, @IChatPetService private readonly chatPetService: IChatPetService, @IThemeService private readonly themeService: IThemeService, @IHoverService private readonly hoverService: IHoverService, @@ -58,17 +60,17 @@ export class SessionsChatPetAchievementBadges extends Disposable { this.chatPetService.enabled.read(reader), this.chatPetService.unlockedAchievements.read(reader), ); - const variant = this.chatPetService.variant.read(reader); + const color = this.chatPetService.color.read(reader); const selectedAccessory = this.chatPetService.selectedAccessory.read(reader); themeChanged.read(reader); - this.render(badges, selectedAccessory, variant); + this.render(badges, selectedAccessory, color); })); } - private render(badges: readonly ISessionsChatPetAchievementBadge[] | undefined, selectedAccessory: ChatPetAccessoryId | undefined, variant: ChatPetVariant): void { + private render(badges: readonly ISessionsChatPetAchievementBadge[] | undefined, selectedAccessory: ChatPetAccessoryId | undefined, color: ChatPetColor): void { const activeElement = DOM.getActiveElement(); - const focusedAccessoryId = DOM.isHTMLElement(activeElement) - ? activeElement.closest('.sessions-chat-pet-achievement-badge')?.dataset.accessoryId + const focusedAchievementId = DOM.isHTMLElement(activeElement) + ? activeElement.closest('.sessions-chat-pet-achievement-badge')?.dataset.achievementId : undefined; const restoreViewAchievementsFocus = DOM.isHTMLElement(activeElement) && activeElement.closest('.sessions-chat-pet-achievement-badges-actions') !== null; let focusTarget: HTMLElement | undefined; @@ -95,17 +97,17 @@ export class SessionsChatPetAchievementBadges extends Disposable { item.setAttribute('aria-label', localize('sessionsChatPetBadgeLockedLabel', "Locked secret achievement badge")); } const badgeElement = unlocked - ? this.createUnlockedBadgeButton(item, achievement, accessory.id, selectedAccessory === accessory.id) + ? this.createUnlockedBadgeButton(item, achievement, accessory?.id, !!accessory && selectedAccessory === accessory.id) : DOM.append(item, DOM.$('span.sessions-chat-pet-achievement-badge.locked', { 'aria-hidden': 'true' })); - if (accessory.id === focusedAccessoryId) { + if (achievement.id === focusedAchievementId) { focusTarget = badgeElement; } const canvas = DOM.append(badgeElement, DOM.$('canvas.sessions-chat-pet-achievement-badge-preview')) as HTMLCanvasElement; canvas.width = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; canvas.height = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; canvas.setAttribute('aria-hidden', 'true'); - this.renderDisposables.add(renderChatPetAchievementPreview(canvas, accessory, unlocked, variant, this.themeService, this.logService)); - this.renderDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), badgeElement, unlocked ? accessory.label : localize('sessionsChatPetBadgeLocked', "Locked"))); + this.renderDisposables.add(renderChatPetAchievementPreview(canvas, accessory, unlocked, color, this.themeService, this.logService)); + this.renderDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), badgeElement, unlocked ? getChatPetAchievementRewardLabels(achievement).join(', ') : localize('sessionsChatPetBadgeLocked', "Locked"))); } const actions = DOM.append(this.element, DOM.$('.sessions-chat-pet-achievement-badges-actions')); const viewAchievements = this.renderDisposables.add(new Button(actions, { @@ -114,7 +116,7 @@ export class SessionsChatPetAchievementBadges extends Disposable { ariaLabel: localize('sessionsChatPetViewAchievementsAriaLabel', "View Pet Achievements"), })); viewAchievements.label = localize('sessionsChatPetViewAchievements', "View Achievements"); - this.renderDisposables.add(viewAchievements.onDidClick(() => this.onOpenAchievements())); + this.renderDisposables.add(viewAchievements.onDidClick(() => this.onOpenCustomization('achievements'))); if (restoreViewAchievementsFocus) { focusTarget = viewAchievements.element; } @@ -128,23 +130,33 @@ export class SessionsChatPetAchievementBadges extends Disposable { } } - private createUnlockedBadgeButton(parent: HTMLElement, achievement: IChatPetAchievement, accessoryId: ChatPetAccessoryId, selected: boolean): HTMLElement { - const accessory = achievement.accessories[0]; + private createUnlockedBadgeButton(parent: HTMLElement, achievement: IChatPetAchievement, accessoryId: ChatPetAccessoryId | undefined, selected: boolean): HTMLElement { + const reward = getChatPetAchievementRewardLabels(achievement).join(', '); const button = this.renderDisposables.add(new Button(parent, { - ariaLabel: selected - ? localize('sessionsChatPetBadgeSelectedLabel', "{0} achievement badge: {1}, wearing", achievement.title, accessory.label) - : localize('sessionsChatPetBadgeLabel', "{0} achievement badge: wear {1}", achievement.title, accessory.label), + ariaLabel: achievement.colorCustomization + ? localize('sessionsChatPetBadgeColorLabel', "{0} achievement badge: change Blobby's color", achievement.title) + : selected + ? localize('sessionsChatPetBadgeSelectedLabel', "{0} achievement badge: {1}, wearing", achievement.title, reward) + : localize('sessionsChatPetBadgeLabel', "{0} achievement badge: wear {1}", achievement.title, reward), })); button.element.classList.add('sessions-chat-pet-achievement-badge'); button.element.classList.toggle('wearing', selected); - button.element.dataset.accessoryId = accessoryId; - button.element.setAttribute('aria-pressed', String(selected)); + button.element.dataset.achievementId = achievement.id; + if (accessoryId) { + button.element.dataset.accessoryId = accessoryId; + button.element.setAttribute('aria-pressed', String(selected)); + } this.renderDisposables.add(button.onDidClick(() => { + if (achievement.colorCustomization) { + this.chatPetService.markAchievementSeen(achievement.id); + this.onOpenCustomization('color'); + return; + } if (this.chatPetService.selectedAccessory.get() === accessoryId) { return; } this.chatPetService.setAccessory(accessoryId); - status(localize('sessionsChatPetBadgeHatSelected', "VS Code pet is now wearing {0}", accessory.label)); + status(localize('sessionsChatPetBadgeHatSelected', "VS Code pet is now wearing {0}", reward)); })); return button.element; } diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts b/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts index ad2da507795f..31f709b735e7 100644 --- a/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts +++ b/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts @@ -19,6 +19,8 @@ import { TestThemeService } from '../../../../../platform/theme/test/common/test import { CHAT_SETUP_ACTION_ID } from '../../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { ChatPetAccessoryId, ChatPetAccessoryIds, ChatPetAchievementId, ChatPetAchievementIds } from '../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; import { ChatPetVariant, IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { ChatPetCustomizationTab } from '../../../../../workbench/contrib/chat/browser/chatPetAchievementsEditorInput.js'; +import { NullHoverService } from '../../../../../platform/hover/test/browser/nullHoverService.js'; import { Menus } from '../../../../browser/menus.js'; import { getChatGPTRateLimitResetHover, shouldShowAccountPanelSummary } from '../../browser/account.contribution.js'; import { getSessionsChatPetAchievementBadges, SessionsChatPetAchievementBadges } from '../../browser/chatPetAchievementBadges.js'; @@ -101,6 +103,7 @@ suite('Sessions - Account Menu', () => { { id: ChatPetAchievementIds.ChatReferenceOpened, unlocked: false }, { id: ChatPetAchievementIds.UsefulOutputCopied, unlocked: false }, { id: ChatPetAchievementIds.AutopilotEnabled, unlocked: false }, + { id: ChatPetAchievementIds.Blobby, unlocked: false }, ], partial: [ { id: ChatPetAchievementIds.FirstChatMessage, unlocked: true }, @@ -116,6 +119,7 @@ suite('Sessions - Account Menu', () => { { id: ChatPetAchievementIds.ChatReferenceOpened, unlocked: false }, { id: ChatPetAchievementIds.UsefulOutputCopied, unlocked: false }, { id: ChatPetAchievementIds.AutopilotEnabled, unlocked: false }, + { id: ChatPetAchievementIds.Blobby, unlocked: false }, ], }); }); @@ -131,7 +135,7 @@ suite('Sessions - Account Menu', () => { override readonly enabled = constObservable(true); override readonly unlockedAchievements = constObservable([ChatPetAchievementIds.FirstChatMessage]); override readonly selectedAccessory = selectedAccessory; - override readonly variant = variant; + override readonly color = variant; override setAccessory(accessory: ChatPetAccessoryId | undefined): void { selected = accessory; @@ -181,4 +185,29 @@ suite('Sessions - Account Menu', () => { focusedAction: 'View Pet Achievements', }); }); + + test('routes True Name and View Achievements through the account owner navigation callback', () => { + const parent = mainWindow.document.createElement('div'); + store.add(toDisposable(() => parent.remove())); + const openedTabs: ChatPetCustomizationTab[] = []; + const seen: ChatPetAchievementId[] = []; + const chatPetService = new class extends mock() { + override readonly enabled = constObservable(true); + override readonly unlockedAchievements = constObservable([ChatPetAchievementIds.Blobby]); + override readonly selectedAccessory = constObservable(undefined); + override readonly color = constObservable('stable'); + override markAchievementSeen(id: ChatPetAchievementId): boolean { seen.push(id); return true; } + }(); + store.add(new SessionsChatPetAchievementBadges( + parent, + tab => openedTabs.push(tab), + chatPetService, + new TestThemeService(), + NullHoverService, + new NullLogService(), + )); + parent.querySelector('[data-achievement-id="blobby"]')?.click(); + parent.querySelector('.sessions-chat-pet-achievement-badges-actions .monaco-button')?.click(); + assert.deepStrictEqual({ openedTabs, seen }, { openedTabs: ['color', 'achievements'], seen: [ChatPetAchievementIds.Blobby] }); + }); }); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 879719b3d866..8587b98fdd02 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -164,6 +164,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.backgroundTint', "When a background image is set, use Tint Window to Match Background in the Command Palette or the background context menu to turn matching window colors on or off. A check mark means tinting is enabled. Turning it off keeps the background image and restores the original window colors.")); content.push(localize('sessionsChat.vscodePet', "Use the checked Pet item in the new-session view context menu, or type /vscode-pet, to show or hide the VS Code pet above the input. Drag it horizontally to reposition it, or use Tab to focus it and the left and right arrow keys to move it. Press Enter or Space to show it some love.")); content.push(localize('sessionsChat.vscodePetAchievements', "When the pet is enabled, the user account menu lists unlocked achievement badges before locked badges and provides a View Achievements button. A gold star on the pet announces a newly unlocked achievement; activate the pet while the star is visible to open Achievements.")); + content.push(localize('sessionsChat.blobby', "Type /blobby to unlock True Name without sending a chat request. If Blobby is off, it turns on; if it is already on, the Color tab opens. Change Color in the pet's context menu always opens Color. Stable and Insiders are free choices; True Name unlocks solid and custom colors. Activating the True Name achievement card or account badge also opens Color. Use the color picker or enter an opaque hex color such as #ff8800, then activate Apply Color. Unapplied custom colors are discarded when you leave the tab or close the modal.")); content.push(localize('sessionsChat.aquariumAction', "To show or hide the aquarium action on the new-session view, use the checked Aquarium item in the context menu outside the composer, or run the Toggle Aquarium Action Visibility command.")); content.push(localize('sessionsChat.dictation', "When dictation is configured, dictate your message into the input{0}. Tap to start and stop, or hold to dictate only while pressed. If the speech-to-text model is still preparing, activate the dictation control again to cancel.", '')); content.push(localize('sessionsChat.voiceMode', "Start or stop Voice Mode to interact with the agent using your microphone{0}.", '')); diff --git a/src/vs/sessions/contrib/chat/browser/slashCommands.ts b/src/vs/sessions/contrib/chat/browser/slashCommands.ts index fdbf5d65c400..5d04eba83a08 100644 --- a/src/vs/sessions/contrib/chat/browser/slashCommands.ts +++ b/src/vs/sessions/contrib/chat/browser/slashCommands.ts @@ -17,6 +17,7 @@ import { Range } from '../../../../editor/common/core/range.js'; import { getWordAtText } from '../../../../editor/common/core/wordHelper.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { localize } from '../../../../nls.js'; import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { IChatSubmitRequestHandlerService, type IChatSubmitRequest, type IChatSubmitRequestHandler } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; @@ -27,6 +28,8 @@ import { getChatSessionType } from '../../../../workbench/contrib/chat/common/mo import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { CHAT_PET_BLOBBY_COMMAND_ID } from '../../../../workbench/contrib/chat/browser/chatPetColors.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; /** * Static command ID used by completion items to trigger immediate slash command execution, * mirroring the pattern of core's `ChatSubmitAction` for `executeImmediately` commands. @@ -49,6 +52,7 @@ interface ISessionsSlashCommandData { readonly sortText?: string; readonly executeImmediately?: boolean; readonly supportsAgentHost?: boolean; + readonly when?: ContextKeyExpression; readonly execute: (args: string) => void; } @@ -79,6 +83,7 @@ export class SlashCommandHandler extends Disposable implements IChatSubmitReques @ISessionContext private readonly sessionContext: ISessionContext, @IChatPetService private readonly chatPetService: IChatPetService, @IChatSubmitRequestHandlerService submitRequestHandlerService: IChatSubmitRequestHandlerService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { super(); this._commandDecorations = this._editor.createDecorationsCollection(); @@ -167,6 +172,14 @@ export class SlashCommandHandler extends Disposable implements IChatSubmitReques executeImmediately: true, execute: () => this.chatPetService.toggle(), }); + this._slashCommands.push({ + command: 'blobby', + detail: localize('slashCommand.blobby', "Show Blobby or open its color customization"), + sortText: 'z3_blobby', + executeImmediately: true, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Setup.hidden.negate()), + execute: () => this.commandService.executeCommand(CHAT_PET_BLOBBY_COMMAND_ID), + }); this._slashCommands.push({ command: 'agents', detail: localize('slashCommand.agents', "View and manage custom agents"), @@ -360,6 +373,9 @@ export class SlashCommandHandler extends Disposable implements IChatSubmitReques } private _isSlashCommandAvailable(command: ISessionsSlashCommandData): boolean { + if (command.when && !this.contextKeyService.contextMatchesRules(command.when)) { + return false; + } const activeSession = this.sessionContext.session.get(); return command.supportsAgentHost !== false || !activeSession diff --git a/src/vs/sessions/contrib/chat/test/browser/slashCommands.test.ts b/src/vs/sessions/contrib/chat/test/browser/slashCommands.test.ts index 874ccd91e8b1..45b13822ea6f 100644 --- a/src/vs/sessions/contrib/chat/test/browser/slashCommands.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/slashCommands.test.ts @@ -19,11 +19,16 @@ import { createTextModel } from '../../../../../editor/test/common/testTextModel import { withTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { AICustomizationManagementCommands } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { CHAT_PET_BLOBBY_COMMAND_ID } from '../../../../../workbench/contrib/chat/browser/chatPetColors.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IChatSubmitRequestHandlerService } from '../../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; @@ -54,6 +59,7 @@ suite('SlashCommandHandler', () => { }; const services = new ServiceCollection( [ICommandService, commandService], + [IContextKeyService, store.add(new ContextKeyService(new TestConfigurationService()))], [ISessionContext, { _serviceBrand: undefined, session }], [ICustomizationHarnessService, new class extends mock() { override readonly onDidChangeSlashCommands = Event.None; @@ -68,6 +74,9 @@ suite('SlashCommandHandler', () => { const model = store.add(createTextModel('', null, undefined, URI.from({ scheme: Schemas.sessionsChatInput, path: '/input' }))); await withTestCodeEditor(model, { serviceCollection: services }, async (editor, _viewModel, instantiationService) => { + const chatEnabled = ChatContextKeys.enabled.bindTo(instantiationService.get(IContextKeyService)); + const chatHidden = ChatContextKeys.Setup.hidden.bindTo(instantiationService.get(IContextKeyService)); + chatEnabled.set(true); const handler = store.add(instantiationService.createInstance(SlashCommandHandler, editor)); const agentHostHandled = await handler.tryHandle({ sessionResource: session.get()!.resource, @@ -75,6 +84,7 @@ suite('SlashCommandHandler', () => { sessionId: 'session', input: '/skills', }); + const agentHostBlobbyHandled = handler.tryExecuteSlashCommand('/blobby'); const localResource = URI.from({ scheme: Schemas.vscodeLocalChatSession, path: '/session' }); session.set(new class extends mock() { @@ -86,6 +96,7 @@ suite('SlashCommandHandler', () => { sessionId: 'session', input: '/skills', }); + const localBlobbyHandled = handler.tryExecuteSlashCommand('/blobby'); model.setValue('/'); const foreignModel = store.add(createTextModel('/', null, undefined, URI.from({ scheme: Schemas.sessionsChatInput, path: '/foreign-input' }))); @@ -94,18 +105,39 @@ suite('SlashCommandHandler', () => { const completionContext = { triggerKind: CompletionTriggerKind.Invoke } as const; const ownCompletions = await staticProvider.provideCompletionItems(model, new Position(1, 2), completionContext, CancellationToken.None); const foreignCompletions = await staticProvider.provideCompletionItems(foreignModel, new Position(1, 2), completionContext, CancellationToken.None); + chatHidden.set(true); + const hiddenCompletions = await staticProvider.provideCompletionItems(model, new Position(1, 2), completionContext, CancellationToken.None); + const hiddenBlobbyHandled = handler.tryExecuteSlashCommand('/blobby'); + chatHidden.set(false); + const restoredCompletions = await staticProvider.provideCompletionItems(model, new Position(1, 2), completionContext, CancellationToken.None); + chatEnabled.set(false); + const disabledCompletions = await staticProvider.provideCompletionItems(model, new Position(1, 2), completionContext, CancellationToken.None); assert.deepStrictEqual({ agentHostHandled, localHandled, + agentHostBlobbyHandled, + localBlobbyHandled, + hiddenBlobbyHandled, + hiddenBlobbyOffered: hiddenCompletions?.suggestions.some(item => item.label === '/blobby'), + restoredBlobbyOffered: restoredCompletions?.suggestions.some(item => item.label === '/blobby'), + disabledBlobbyHandled: handler.tryExecuteSlashCommand('/blobby'), + disabledBlobbyOffered: disabledCompletions?.suggestions.some(item => item.label === '/blobby'), commandCalls, ownCommands: ownCompletions?.suggestions.map(item => typeof item.label === 'string' ? item.label : item.label.label), foreignCommands: foreignCompletions?.suggestions, }, { agentHostHandled: false, localHandled: true, - commandCalls: [AICustomizationManagementCommands.OpenEditor], - ownCommands: ['/vscode-pet', '/agents', '/skills', '/instructions', '/hooks', '/models'], + agentHostBlobbyHandled: true, + localBlobbyHandled: true, + hiddenBlobbyHandled: false, + hiddenBlobbyOffered: false, + restoredBlobbyOffered: true, + disabledBlobbyHandled: false, + disabledBlobbyOffered: false, + commandCalls: [CHAT_PET_BLOBBY_COMMAND_ID, AICustomizationManagementCommands.OpenEditor, CHAT_PET_BLOBBY_COMMAND_ID], + ownCommands: ['/vscode-pet', '/blobby', '/agents', '/skills', '/instructions', '/hooks', '/models'], foreignCommands: undefined, }); }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 84f46b29ddea..68b81e06066f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -123,7 +123,8 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.inputBlocked', 'When another Codex app is using a conversation, a banner above the input explains how to release it. You can keep editing your draft, but sending is disabled. Quit the app holding the conversation, such as ChatGPT, or exit the Codex CLI session. Use Tab to focus Retry in the banner, then press Enter or Space to check again. Retry does not send your draft.')); content.push(localize('chat.subagentPills', "Subagents appear as pills in the response. Use Tab to focus a subagent pill, then Enter or Space to open its chat to the side. If the subagent has no separate chat, Enter or Space expands or collapses its inline details instead. The pill announces its running state, model settings, and any pending confirmations. File and markdown links in its activity row can be focused with Tab and opened independently with Enter.")); content.push(localize('chat.nestedSubagentActivity', "When a subagent starts another subagent, its activity row identifies the nested subagent by name. Focus that link with Tab and press Enter or Space to open the nested subagent's chat to the side. The enclosing pill still opens the parent subagent.")); - content.push(localize('chat.vscodePet', 'Type /vscode-pet to show or hide the VS Code pet above the input. One pet appears in whichever editor or Agents window is active. Drag it around the chat with the mouse and release it to drop it, or flick it in any direction to throw it along the gesture before gravity pulls it down. Pointer collisions are ignored for half a second after a drag release. After that, while the pet is falling, move the pointer into it to bounce it upward; pointer movement and where it catches the pet affect the bounce. Sideways and upward travel do not start the bounce counter. A counter beside the pet tracks consecutive bounces and remains for up to five seconds after landing, or until the pet next reacts or interacts. Landing with at least twenty bounces triggers confetti unless reduced motion is enabled. If it falls past the input, a despawn effect appears at the bottom and a respawn effect appears at the top before it automatically returns to the input. Moving the pointer rapidly between the pet\u2019s left and right sides makes it dizzy. With the keyboard, use Tab to focus the pet, then the left and right arrows to make it hop along the input until it reaches an edge. Hold Shift with the left or right arrow to throw it toward a wall; while it is airborne, press Enter or Space to bounce it upward. Rapidly alternate the unmodified arrows to make it dizzy. Press Enter or Space while it is resting to interact with it. When an achievement unlocks, the pet shows a gold star for ten seconds; activate the pet during that time to open Achievements. Open its context menu{0} (for example Shift+F10), use the up and down arrow keys to choose Achievements, Go on the Run, Come Back, Grow, Shrink, Reset Size, Stable Colors, or Insiders Colors, and press Enter to activate the choice. Grow and Shrink change its size in twenty-percent steps, while Reset Size restores its default size. The pet position and selected size are shared across chats and windows and remembered after you restart.', '')); + content.push(localize('chat.vscodePet', 'Type /vscode-pet to show or hide Blobby, the VS Code pet, above the input. One pet appears in whichever editor or Agents window is active. Drag it around the chat with the mouse and release it to drop it, or flick it in any direction to throw it along the gesture before gravity pulls it down. Pointer collisions are ignored for half a second after a drag release. After that, while the pet is falling, move the pointer into it to bounce it upward; pointer movement and where it catches the pet affect the bounce. Sideways and upward travel do not start the bounce counter. A counter beside the pet tracks consecutive bounces and remains for up to five seconds after landing, or until the pet next reacts or interacts. Landing with at least twenty bounces triggers confetti unless reduced motion is enabled. If it falls past the input, a despawn effect appears at the bottom and a respawn effect appears at the top before it automatically returns to the input. Moving the pointer rapidly between the pet\u2019s left and right sides makes it dizzy. With the keyboard, use Tab to focus the pet, then the left and right arrows to make it hop along the input until it reaches an edge. Hold Shift with the left or right arrow to throw it toward a wall; while it is airborne, press Enter or Space to bounce it upward. Rapidly alternate the unmodified arrows to make it dizzy. Press Enter or Space while it is resting to interact with it. When an achievement unlocks, the pet shows a gold star for ten seconds; activate the pet during that time to open Achievements. Open its context menu{0} (for example Shift+F10), use the up and down arrow keys to choose Achievements, Go on the Run, Come Back, Grow, Shrink, Reset Size, or Change Color, and press Enter to activate the choice. Grow and Shrink change its size in twenty-percent steps, while Reset Size restores its default size. The pet position, size, and color are shared across chats and windows and remembered after you restart.', '')); + content.push(localize('chat.blobby', "Type /blobby to unlock the True Name achievement without sending a chat request. If Blobby is off, the command turns it on; if it is already on, it opens the Color tab in Customize Blobby. Change Color in the pet's context menu always opens Color, where Stable and Insiders are free choices. True Name unlocks solid presets and custom colors; activating that achievement also opens Color. Use the color picker or enter an opaque hex color such as #ff8800, then activate Apply Color. Unapplied custom colors are discarded when you leave the tab or close the modal.")); if (supportsFileReferences) { content.push(localize('chat.attachments.inlineReferences', 'To mention an attached context item at a specific position without removing it from the attached context, type # or @ and select the attachment from the suggestions.')); content.push(localize('chat.attachments.inlineReferenceHover', 'To inspect an inline attachment reference, place the cursor on it and invoke Show or Focus Hover{0}. Image references include a preview, while file and folder references include their path.', '')); diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts index 45325a1e5051..288cea09124d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts @@ -9,7 +9,7 @@ import { FileAccess } from '../../../../base/common/network.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { foreground } from '../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { ChatPetVariant } from './chatPetService.js'; +import { ChatPetColor, getChatPetColorVariant, setChatPetImageSource } from './chatPetColors.js'; import { IChatPetAccessory } from './chatPetAchievements.js'; import { drawChatPetAccessory, drawChatPetComposite, getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from './widget/chatPetAccessoryRenderer.js'; @@ -19,7 +19,7 @@ export function renderChatPetAchievementPreview( canvas: HTMLCanvasElement, accessory: IChatPetAccessory | undefined, unlocked: boolean, - variant: ChatPetVariant, + color: ChatPetColor, themeService: IThemeService, logService: ILogService, ): IDisposable { @@ -27,8 +27,9 @@ export function renderChatPetAchievementPreview( const bodyImage = DOM.$('img') as HTMLImageElement; const accessoryImage = accessory ? DOM.$('img') as HTMLImageElement : undefined; const accessorySource = accessory ? getChatPetAccessoryImageSource(accessory) : undefined; - const bodySource = FileAccess.asBrowserUri(`vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-idle-${variant}-96.png`).toString(true); - let bodyLoaded = !unlocked; + const bodySource = FileAccess.asBrowserUri(`vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-idle-${getChatPetColorVariant(color)}-96.png`).toString(true); + const showBody = unlocked || accessory === undefined; + let bodyLoaded = !showBody; let accessoryLoaded = accessory === undefined; const draw = () => { if (!bodyLoaded || !accessoryLoaded) { @@ -54,14 +55,16 @@ export function renderChatPetAchievementPreview( true, accessory?.eyeAccessoryMirrorsWithFacing !== false, accessory?.coversAntennae === true, + color, ); return; } context.clearRect(0, 0, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE); - if (!accessoryImage) { - return; + if (accessoryImage) { + drawChatPetAccessory(context, accessoryImage, 'idle', 0, 'right'); + } else { + context.drawImage(bodyImage, 0, 0); } - drawChatPetAccessory(context, accessoryImage, 'idle', 0, 'right'); context.globalCompositeOperation = 'source-in'; const silhouetteColor = themeService.getColorTheme().getColor(foreground); if (!silhouetteColor) { @@ -74,7 +77,7 @@ export function renderChatPetAchievementPreview( context.globalCompositeOperation = 'source-over'; }; - if (unlocked) { + if (showBody) { store.add(DOM.addDisposableListener(bodyImage, 'load', () => { if (!hasChatPetBodyImageDimensions(bodyImage, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, 1)) { logService.error(`[ChatPetAchievementPreview] Invalid preview body dimensions: ${bodySource}`); @@ -86,7 +89,7 @@ export function renderChatPetAchievementPreview( store.add(DOM.addDisposableListener(bodyImage, 'error', () => { logService.error(`[ChatPetAchievementPreview] Failed to load preview body: ${bodySource}`); })); - bodyImage.src = bodySource; + setChatPetImageSource(bodyImage, bodySource); } if (accessoryImage && accessorySource) { store.add(DOM.addDisposableListener(accessoryImage, 'load', () => { @@ -100,7 +103,7 @@ export function renderChatPetAchievementPreview( store.add(DOM.addDisposableListener(accessoryImage, 'error', () => { logService.error(`[ChatPetAchievementPreview] Failed to load preview accessory: ${accessorySource.url}`); })); - accessoryImage.src = accessorySource.url; + setChatPetImageSource(accessoryImage, accessorySource.url); } return store; diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts index a2ea0a094825..52f910efc143 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts @@ -25,6 +25,7 @@ import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/edit import { IWorkbenchContribution } from '../../../common/contributions.js'; import { EditorExtensions } from '../../../common/editor.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { AICustomizationManagementSection } from '../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../common/customizationHarnessService.js'; @@ -32,14 +33,15 @@ import { IAICustomizationItemSource, IAICustomizationListItem } from './aiCustom import { IAICustomizationItemsModel } from './aiCustomization/aiCustomizationItemsModel.js'; import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, chatPetAchievements, ChatPetAchievementIds, isUserAuthoredChatPetCustomization } from './chatPetAchievements.js'; import { ChatPetAchievementsContextKeys, ChatPetAchievementsEditor } from './chatPetAchievementsEditor.js'; -import { ChatPetAchievementsEditorInput } from './chatPetAchievementsEditorInput.js'; +import { ChatPetAchievementsEditorInput, IChatPetCustomizationEditorOptions } from './chatPetAchievementsEditorInput.js'; import { ChatPetContextKeys, IChatPetService } from './chatPetService.js'; +import { CHAT_PET_BLOBBY_COMMAND_ID, CHAT_PET_CHANGE_COLOR_COMMAND_ID } from './chatPetColors.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create( ChatPetAchievementsEditor, ChatPetAchievementsEditor.ID, - localize('chatPet.achievements.editor', "Achievements Editor"), + localize('chatPet.achievements.editor', "Blobby Customization Editor"), ), [new SyncDescriptor(ChatPetAchievementsEditorInput)], ); @@ -91,10 +93,11 @@ registerAction2(class extends Action2 { } async run(accessor: ServicesAccessor): Promise { - if (!accessor.get(IChatPetService).enabled.get()) { + if (!accessor.get(IChatPetService).enabled.get() || accessor.get(IChatEntitlementService).sentiment.hidden) { return; } - await accessor.get(IEditorService).openEditor(ChatPetAchievementsEditorInput.getOrCreate(), { pinned: true }); + const options: IChatPetCustomizationEditorOptions = { pinned: true, tab: 'achievements' }; + await accessor.get(IEditorService).openEditor(ChatPetAchievementsEditorInput.getOrCreate(), options); } }); @@ -133,6 +136,51 @@ export class ChatPetContextContribution extends Disposable implements IWorkbench } } +registerAction2(class extends Action2 { + constructor() { + super({ + id: CHAT_PET_BLOBBY_COMMAND_ID, + title: localize2('chatPet.blobby', "Customize Blobby"), + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Setup.hidden.negate()), + }); + } + + async run(accessor: ServicesAccessor): Promise { + if (accessor.get(IChatEntitlementService).sentiment.hidden) { + return; + } + const chatPetService = accessor.get(IChatPetService); + const wasEnabled = chatPetService.enabled.get(); + if (!wasEnabled) { + chatPetService.toggle(); + } + chatPetService.unlockAchievement(ChatPetAchievementIds.Blobby); + if (wasEnabled) { + await accessor.get(ICommandService).executeCommand(CHAT_PET_CHANGE_COLOR_COMMAND_ID); + } + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: CHAT_PET_CHANGE_COLOR_COMMAND_ID, + title: localize2('chatPet.changeColor', "Change Blobby's Color"), + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Setup.hidden.negate(), ChatPetContextKeys.enabled), + f1: true, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const chatPetService = accessor.get(IChatPetService); + if (!chatPetService.enabled.get() || accessor.get(IChatEntitlementService).sentiment.hidden) { + return; + } + const options: IChatPetCustomizationEditorOptions = { pinned: true, tab: 'color' }; + await accessor.get(IEditorService).openEditor(ChatPetAchievementsEditorInput.getOrCreate(), options); + } +}); + const CHAT_PET_KEEP_EDIT_COMMAND_IDS = new Set([ 'chatEditing.acceptFile', 'chatEditing.acceptAllFiles', @@ -337,10 +385,11 @@ export class ChatPetAchievementsAccessibilityHelp implements IAccessibleViewImpl const previouslyFocusedElement = DOM.getActiveElement(); const editorService = _accessor.get(IEditorService); const content = [ - localize('chatPet.achievements.accessibilityHelp.overview', "The Achievements modal lists agent-feature achievements and their pet hat rewards. Locked cards reveal a hint and reward while keeping the achievement name and exact unlock requirement hidden."), + localize('chatPet.achievements.accessibilityHelp.overview', "Customize Blobby has Achievements and Color tabs. When the page opens, press Tab to focus the selected tab. Use the Left and Right Arrow keys, Home, or End while a tab is focused to switch tabs. Press Tab again to reach the selected tab's content. The Achievements tab lists agent-feature achievements and their pet hat and color rewards. Locked cards reveal a hint and reward while keeping the achievement name and exact unlock requirement hidden."), localize('chatPet.achievements.accessibilityHelp.cards', "Use Tab and Shift+Tab to move through No Hat and the achievement cards. Press Enter or Space on No Hat or an unlocked achievement to change what the pet wears. Newly unlocked cards are announced as New until you activate them. Locked achievements announce their hint and reward and cannot be selected."), + localize('chatPet.achievements.accessibilityHelp.colors', "Change Color in Blobby's context menu always opens the Color tab. Stable and Insiders are available without an achievement. Use /blobby in Chat to unlock True Name and its solid and custom colors. If the pet is off, the command turns it on; if it is already on, it opens the Color tab. Activating the True Name achievement also opens Color. Use Tab to reach a color card, then Enter or Space to apply it. For a custom color, use the color picker or enter an opaque hex value such as #ff8800, then activate Apply Color or press Enter in the hex input. The preview does not change the pet until you apply it."), localize('chatPet.achievements.accessibilityHelp.roadmap', "The final TBD card is informational and lists upcoming pet ideas. The VS Code pet and achievements are experimental and may change."), - localize('chatPet.achievements.accessibilityHelp.close', "Press Escape to close the Achievements modal."), + localize('chatPet.achievements.accessibilityHelp.close', "Press Escape to close Customize Blobby. Unapplied custom color changes are discarded."), ].join('\n\n'); return new AccessibleContentProvider( AccessibleViewProviderId.ChatPetAchievements, diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts index 7e4a48fd950e..46bc10f9e6cf 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts @@ -26,6 +26,7 @@ export const ChatPetAchievementIds = { McpServerPresent: 'mcpServerPresent', InstructionPresent: 'instructionPresent', ImageRequest: 'imageRequest', + Blobby: 'blobby', } as const; export type ChatPetAchievementId = typeof ChatPetAchievementIds[keyof typeof ChatPetAchievementIds]; @@ -66,13 +67,14 @@ export interface IChatPetAchievement { readonly title: string; readonly description: string; readonly hint: string; - readonly accessories: readonly [IChatPetAccessory]; + readonly accessories: readonly [] | readonly [IChatPetAccessory]; + readonly colorCustomization?: boolean; readonly enabled: boolean; } export type ChatPetAchievementPresentation = | { readonly locked: true; readonly id: ChatPetAchievementId; readonly hint: string; readonly rewardLabels: readonly string[] } - | { readonly locked: false; readonly id: ChatPetAchievementId; readonly title: string; readonly description: string; readonly accessories: readonly IChatPetAccessory[] }; + | { readonly locked: false; readonly id: ChatPetAchievementId; readonly title: string; readonly description: string; readonly accessories: readonly IChatPetAccessory[]; readonly rewardLabels: readonly string[] }; const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ { @@ -270,6 +272,15 @@ const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ coversAntennae: true, }], }, + { + id: ChatPetAchievementIds.Blobby, + title: localize('chatPet.achievement.blobby.title', "True Name"), + description: localize('chatPet.achievement.blobby.description', "Blob the Builder? Blobby McBlobface? Nope, my name is Blobby."), + hint: localize('chatPet.achievement.blobby.hint', "This little blob has a name. Try calling it in Chat."), + enabled: true, + accessories: [], + colorCustomization: true, + }, ]; export const disabledChatPetAchievements: readonly IChatPetAchievement[] = [ @@ -416,6 +427,7 @@ export function getChatPetCustomizationAchievementIds( } export function getChatPetAchievementPresentation(achievement: IChatPetAchievement, unlocked: boolean): ChatPetAchievementPresentation { + const rewardLabels = getChatPetAchievementRewardLabels(achievement); return unlocked ? { locked: false, @@ -423,15 +435,22 @@ export function getChatPetAchievementPresentation(achievement: IChatPetAchieveme title: achievement.title, description: achievement.description, accessories: achievement.accessories, + rewardLabels, } : { locked: true, id: achievement.id, hint: achievement.hint, - rewardLabels: achievement.accessories.map(accessory => accessory.label), + rewardLabels, }; } +export function getChatPetAchievementRewardLabels(achievement: IChatPetAchievement): readonly string[] { + return achievement.colorCustomization + ? [localize('chatPet.achievement.colorCustomization', "Color Customization")] + : achievement.accessories.map(accessory => accessory.label); +} + export function getUnlockedChatPetAccessories(unlockedAchievements: readonly ChatPetAchievementId[]): readonly IChatPetAccessory[] { const unlocked = new Set(unlockedAchievements); return chatPetAchievements diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts index c8e6cbffeb05..daf7dd7b2161 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts @@ -4,12 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -17,12 +20,14 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ChatPetAchievementsWidget } from './chatPetAchievementsWidget.js'; -import { ChatPetAchievementsEditorInput } from './chatPetAchievementsEditorInput.js'; +import { ChatPetAchievementsEditorInput, ChatPetCustomizationTab, IChatPetCustomizationEditorOptions } from './chatPetAchievementsEditorInput.js'; import { IChatPetService } from './chatPetService.js'; +import { ChatPetColorsWidget } from './chatPetColorsWidget.js'; export const ChatPetAchievementsContextKeys = { - focused: new RawContextKey('chatPetAchievementsFocused', false, localize('chatPet.achievements.context.focused', "Whether the chat pet Achievements modal is focused")), + focused: new RawContextKey('chatPetAchievementsFocused', false, localize('chatPet.achievements.context.focused', "Whether the Blobby customization modal is focused")), }; export class ChatPetAchievementsEditor extends EditorPane { @@ -32,7 +37,10 @@ export class ChatPetAchievementsEditor extends EditorPane { private readonly editorDisposables = this._register(new DisposableStore()); private readonly focusedContextKey: IContextKey; private container: HTMLElement | undefined; - private widget: ChatPetAchievementsWidget | undefined; + private readonly widget = this._register(new MutableDisposable()); + private readonly tabs = new Map(); + private activeTab: ChatPetCustomizationTab = 'achievements'; + private tabBar: HTMLElement | undefined; private dimension: DOM.Dimension | undefined; constructor( @@ -43,43 +51,126 @@ export class ChatPetAchievementsEditor extends EditorPane { @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, @IChatPetService private readonly chatPetService: IChatPetService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { super(ChatPetAchievementsEditor.ID, group, telemetryService, themeService, storageService); this.focusedContextKey = ChatPetAchievementsContextKeys.focused.bindTo(contextKeyService); this._register(toDisposable(() => this.focusedContextKey.reset())); this._register(autorun(reader => { - if (!this.chatPetService.enabled.read(reader) && this.input) { + if ((!this.chatPetService.enabled.read(reader) || this.chatEntitlementService.sentimentObs.read(reader).hidden) && this.input) { void this.group.closeEditor(this.input); } })); } protected override createEditor(parent: HTMLElement): void { + this.widget.clear(); this.editorDisposables.clear(); - this.container = DOM.append(parent, DOM.$('.chat-pet-achievements-editor')); + this.tabs.clear(); + this.container = DOM.append(parent, DOM.$('.chat-pet-achievements-editor', { + role: 'group', + tabindex: '-1', + 'aria-label': localize('chatPet.customization.label', "Customize Blobby"), + })); const focusTracker = this.editorDisposables.add(DOM.trackFocus(this.container)); this.editorDisposables.add(focusTracker.onDidFocus(() => this.focusedContextKey.set(true))); this.editorDisposables.add(focusTracker.onDidBlur(() => this.focusedContextKey.set(false))); - this.widget = this.editorDisposables.add(this.instantiationService.createInstance(ChatPetAchievementsWidget, this.container, () => { - if (this.input) { - void this.group.closeEditor(this.input); - } + this.tabBar = DOM.append(this.container, DOM.$('.chat-pet-customization-tabs', { + role: 'tablist', + 'aria-label': localize('chatPet.customization.tabs', "Blobby customization"), })); + const tabIds: readonly ChatPetCustomizationTab[] = ['achievements', 'color']; + const prefix = generateUuid(); + for (const [index, tab] of tabIds.entries()) { + const button = this.editorDisposables.add(new Button(this.tabBar, {})); + button.label = tab === 'achievements' + ? localize('chatPet.customization.achievements', "Achievements") + : localize('chatPet.customization.color', "Color"); + button.element.setAttribute('role', 'tab'); + button.element.id = `${prefix}-${tab}`; + const panel = DOM.append(this.container, DOM.$('.chat-pet-customization-panel', { + role: 'tabpanel', + id: `${prefix}-${tab}-panel`, + 'aria-labelledby': button.element.id, + })); + button.element.setAttribute('aria-controls', panel.id); + this.tabs.set(tab, { button, panel }); + this.editorDisposables.add(button.onDidClick(() => this.showTab(tab))); + this.editorDisposables.add(button.onDidEscape(() => this.close())); + this.editorDisposables.add(DOM.addDisposableListener(button.element, DOM.EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + const nextIndex = event.equals(KeyCode.LeftArrow) + ? (index + tabIds.length - 1) % tabIds.length + : event.equals(KeyCode.RightArrow) ? (index + 1) % tabIds.length + : event.equals(KeyCode.Home) ? 0 + : event.equals(KeyCode.End) ? tabIds.length - 1 : undefined; + if (nextIndex !== undefined) { + DOM.EventHelper.stop(e, true); + const nextTab = tabIds[nextIndex]; + this.showTab(nextTab); + this.tabs.get(nextTab)?.button.focus(); + } + })); + } + this.showTab(this.activeTab); } - override async setInput(input: ChatPetAchievementsEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + override async setInput(input: ChatPetAchievementsEditorInput, options: IChatPetCustomizationEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { await super.setInput(input, options, context, token); - if (!this.chatPetService.enabled.get()) { + if (!this.chatPetService.enabled.get() || this.chatEntitlementService.sentiment.hidden) { await this.group.closeEditor(input); return; } + this.showTab(options?.tab ?? 'achievements'); if (this.dimension) { this.layout(this.dimension); } } + override setOptions(options: IChatPetCustomizationEditorOptions | undefined): void { + super.setOptions(options); + if (options?.tab) { + this.showTab(options.tab); + } + } + + private showTab(tab: ChatPetCustomizationTab): void { + const previousPanel = this.tabs.get(this.activeTab)?.panel; + const needsWidget = tab !== this.activeTab || !this.widget.value; + this.activeTab = tab; + for (const [id, entry] of this.tabs) { + const selected = id === tab; + entry.button.element.setAttribute('aria-selected', String(selected)); + entry.button.element.tabIndex = selected ? 0 : -1; + entry.panel.hidden = !selected; + } + const panel = this.tabs.get(tab)?.panel; + if (needsWidget && panel) { + this.widget.clear(); + if (previousPanel) { + DOM.clearNode(previousPanel); + } + this.widget.value = tab === 'color' + ? this.instantiationService.createInstance(ChatPetColorsWidget, panel, () => this.close()) + : this.instantiationService.createInstance(ChatPetAchievementsWidget, panel, () => this.close()); + if (this.dimension) { + this.layout(this.dimension); + } + } + } + + private close(): void { + if (this.input) { + void this.group.closeEditor(this.input); + } + } + override clearInput(): void { this.focusedContextKey.set(false); + this.widget.clear(); + for (const { panel } of this.tabs.values()) { + DOM.clearNode(panel); + } super.clearInput(); } @@ -89,11 +180,11 @@ export class ChatPetAchievementsEditor extends EditorPane { this.container.style.width = `${dimension.width}px`; this.container.style.height = `${dimension.height}px`; } - this.widget?.layout(dimension); + this.widget.value?.layout(new DOM.Dimension(dimension.width, Math.max(0, dimension.height - (this.tabBar?.offsetHeight ?? 0)))); } override focus(): void { super.focus(); - this.widget?.focus(); + this.container?.focus(); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.ts index 0ba4090aeda4..bdf13003b68c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.ts @@ -6,10 +6,16 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; -import { IModalEditorOptions, IModalEditorOptionsProvider } from '../../../../platform/editor/common/editor.js'; +import { IEditorOptions, IModalEditorOptions, IModalEditorOptionsProvider } from '../../../../platform/editor/common/editor.js'; import { EditorInputCapabilities, IUntypedEditorInput } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; +export type ChatPetCustomizationTab = 'achievements' | 'color'; + +export interface IChatPetCustomizationEditorOptions extends IEditorOptions { + readonly tab?: ChatPetCustomizationTab; +} + export class ChatPetAchievementsEditorInput extends EditorInput implements IModalEditorOptionsProvider { static readonly ID = 'workbench.editors.chatPetAchievements'; @@ -33,7 +39,7 @@ export class ChatPetAchievementsEditorInput extends EditorInput implements IModa } override getName(): string { - return localize('chatPet.achievements.editorName', "Achievements"); + return localize('chatPet.achievements.editorName', "Customize Blobby"); } override getIcon(): ThemeIcon { diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts index 0d5d69f85fc6..776d1fae433a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts @@ -12,11 +12,13 @@ import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.j import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { renderChatPetAchievementPreview, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE } from './chatPetAchievementPreview.js'; import { chatPetAchievements, ChatPetAccessoryId, ChatPetAchievementId, getChatPetAccessory, getChatPetAchievementPresentation } from './chatPetAchievements.js'; -import { ChatPetVariant, IChatPetService } from './chatPetService.js'; +import { IChatPetService } from './chatPetService.js'; +import { CHAT_PET_CHANGE_COLOR_COMMAND_ID, ChatPetColor } from './chatPetColors.js'; export class ChatPetAchievementsWidget extends Disposable { @@ -32,6 +34,7 @@ export class ChatPetAchievementsWidget extends Disposable { readonly defaultAriaLabel: string; readonly achievementId?: ChatPetAchievementId; readonly newBadge?: HTMLElement; + readonly colorCustomization?: boolean; }>(); private unseenAchievementIds = new Set(); private focusTarget: (() => void) | undefined; @@ -43,6 +46,7 @@ export class ChatPetAchievementsWidget extends Disposable { @IChatPetService private readonly chatPetService: IChatPetService, @IThemeService private readonly themeService: IThemeService, @ILogService private readonly logService: ILogService, + @ICommandService private readonly commandService: ICommandService, ) { super(); @@ -63,9 +67,9 @@ export class ChatPetAchievementsWidget extends Disposable { const themeChanged = observableSignalFromEvent(this, this.themeService.onDidColorThemeChange); this._register(autorun(reader => { const unlockedAchievements = this.chatPetService.unlockedAchievements.read(reader); - const variant = this.chatPetService.variant.read(reader); + const color = this.chatPetService.color.read(reader); themeChanged.read(reader); - this.render(unlockedAchievements, this.chatPetService.selectedAccessory.read(undefined), variant); + this.render(unlockedAchievements, this.chatPetService.selectedAccessory.read(undefined), color); })); this._register(autorun(reader => { this.updateSelectedAccessory(this.chatPetService.selectedAccessory.read(reader)); @@ -96,9 +100,10 @@ export class ChatPetAchievementsWidget extends Disposable { this.focusTarget?.(); } - private render(unlockedAchievements: readonly ChatPetAchievementId[], selectedAccessory: ChatPetAccessoryId | undefined, variant: ChatPetVariant): void { + private render(unlockedAchievements: readonly ChatPetAchievementId[], selectedAccessory: ChatPetAccessoryId | undefined, color: ChatPetColor): void { const activeElement = DOM.getActiveElement(); - const restoreFocusId = DOM.isHTMLElement(activeElement) ? activeElement.closest('.chat-pet-achievement-card')?.dataset.accessoryId : undefined; + const focusedCard = DOM.isHTMLElement(activeElement) ? activeElement.closest('.chat-pet-achievement-card') : undefined; + const restoreFocusId = focusedCard?.dataset.accessoryId ?? focusedCard?.dataset.achievementId; this.renderDisposables.clear(); DOM.clearNode(this.content); this.accessoryCards.clear(); @@ -106,7 +111,7 @@ export class ChatPetAchievementsWidget extends Disposable { const inner = DOM.append(this.content, DOM.$('.chat-pet-achievements-inner')); DOM.append(inner, DOM.$('h1')).textContent = localize('chatPet.achievements.title', "Achievements"); - DOM.append(inner, DOM.$('p.chat-pet-achievements-intro')).textContent = localize('chatPet.achievements.intro', "Unlock hats as you explore agent features, then choose what your pet wears by selecting an unlocked card."); + DOM.append(inner, DOM.$('p.chat-pet-achievements-intro')).textContent = localize('chatPet.achievements.intro', "Unlock hats and colors as you explore agent features, then customize Blobby by selecting an unlocked card."); const unlockedSet = new Set(unlockedAchievements); const collection = DOM.append(inner, DOM.$('section.chat-pet-achievements-collection')); collection.setAttribute('role', 'region'); @@ -141,7 +146,7 @@ export class ChatPetAchievementsWidget extends Disposable { noHatCard.element.setAttribute('aria-pressed', String(noHatSelected)); noHatCard.element.classList.toggle('wearing', noHatSelected); const noHatPreviews = DOM.append(noHatCard.element, DOM.$('.chat-pet-achievement-previews')); - this.renderCardPreview(noHatPreviews, undefined, true, variant); + this.renderCardPreview(noHatPreviews, undefined, true, color); const noHatContent = DOM.append(noHatCard.element, DOM.$('.chat-pet-achievement-card-content')); DOM.append(noHatContent, DOM.$('h3')).textContent = localize('chatPet.achievements.noHat', "No Hat"); const noHatState = DOM.append(noHatContent, DOM.$('span.chat-pet-achievement-state')); @@ -164,27 +169,32 @@ export class ChatPetAchievementsWidget extends Disposable { const presentation = getChatPetAchievementPresentation(achievement, unlocked); const wearing = unlocked && achievement.accessories.some(accessory => selectedAccessory === accessory.id); const item = DOM.append(list, DOM.$('li.chat-pet-achievements-list-item')); - const accessoryId = achievement.accessories[0].id; + const accessory = achievement.accessories[0]; + const cardId = accessory?.id ?? achievement.id; + const rewardLabel = presentation.rewardLabels.join(', '); + const defaultState = achievement.colorCustomization + ? localize('chatPet.achievement.changeColor', "Change Color") + : localize('chatPet.achievement.unlocked', "Unlocked"); const card = this.renderDisposables.add(new Button(item, { secondary: true, ariaLabel: presentation.locked ? localize('chatPet.achievement.lockedAriaLabel', "Locked. Hint: {0} Rewards: {1}.", presentation.hint, presentation.rewardLabels.join(', ')) - : localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", presentation.title, presentation.accessories[0].label, wearing ? localize('chatPet.achievement.wearing', "Wearing") : localize('chatPet.achievement.unlocked', "Unlocked")), + : localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", presentation.title, rewardLabel, wearing ? localize('chatPet.achievement.wearing', "Wearing") : defaultState), })); card.element.classList.add('chat-pet-achievement-card'); - card.element.dataset.accessoryId = accessoryId; + card.element.dataset.achievementId = achievement.id; + if (accessory) { + card.element.dataset.accessoryId = accessory.id; + card.element.setAttribute('aria-pressed', String(wearing)); + } card.element.classList.toggle('locked', !unlocked); card.element.classList.toggle('wearing', wearing); - card.element.setAttribute('aria-pressed', String(wearing)); card.enabled = unlocked; const newBadge = DOM.append(card.element, DOM.$('span.chat-pet-achievement-new-badge.hidden')); newBadge.textContent = localize('chatPet.achievement.new', "New"); newBadge.setAttribute('aria-hidden', 'true'); const previews = DOM.append(card.element, DOM.$('.chat-pet-achievement-previews')); - const previewAccessories = unlocked ? achievement.accessories : [achievement.accessories[0]]; - for (const accessory of previewAccessories) { - this.renderCardPreview(previews, accessory, unlocked, variant); - } + this.renderCardPreview(previews, accessory, unlocked, color); const cardContent = DOM.append(card.element, DOM.$('.chat-pet-achievement-card-content')); if (!presentation.locked) { @@ -192,17 +202,18 @@ export class ChatPetAchievementsWidget extends Disposable { const state = DOM.append(cardContent, DOM.$('span.chat-pet-achievement-state')); state.textContent = wearing ? localize('chatPet.achievement.wearing', "Wearing") - : localize('chatPet.achievement.unlocked', "Unlocked"); + : defaultState; DOM.append(cardContent, DOM.$('p.chat-pet-achievement-description')).textContent = presentation.description; - DOM.append(cardContent, DOM.$('p.chat-pet-achievement-reward')).textContent = localize('chatPet.achievement.rewards', "Rewards: {0}", presentation.accessories.map(accessory => accessory.label).join(', ')); - this.accessoryCards.set(accessoryId, { + DOM.append(cardContent, DOM.$('p.chat-pet-achievement-reward')).textContent = localize('chatPet.achievement.rewards', "Rewards: {0}", rewardLabel); + this.accessoryCards.set(cardId, { button: card, state, - defaultState: localize('chatPet.achievement.unlocked', "Unlocked"), - selectedAriaLabel: localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", achievement.title, achievement.accessories[0].label, localize('chatPet.achievement.wearing', "Wearing")), - defaultAriaLabel: localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", achievement.title, achievement.accessories[0].label, localize('chatPet.achievement.unlocked', "Unlocked")), + defaultState, + selectedAriaLabel: localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", achievement.title, rewardLabel, localize('chatPet.achievement.wearing', "Wearing")), + defaultAriaLabel: localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", achievement.title, rewardLabel, defaultState), achievementId: achievement.id, newBadge, + colorCustomization: achievement.colorCustomization, }); } else { DOM.append(cardContent, DOM.$('h3')).textContent = localize('chatPet.achievement.locked', "Locked"); @@ -210,9 +221,16 @@ export class ChatPetAchievementsWidget extends Disposable { DOM.append(cardContent, DOM.$('p.chat-pet-achievement-description')).textContent = presentation.hint; DOM.append(cardContent, DOM.$('p.chat-pet-achievement-reward')).textContent = localize('chatPet.achievement.rewards', "Rewards: {0}", presentation.rewardLabels.join(', ')); } - this.renderDisposables.add(card.onDidClick(() => this.selectAccessory(accessoryId, achievement.id))); + this.renderDisposables.add(card.onDidClick(async () => { + if (achievement.colorCustomization) { + this.chatPetService.markAchievementSeen(achievement.id); + await this.commandService.executeCommand(CHAT_PET_CHANGE_COLOR_COMMAND_ID); + } else { + this.selectAccessory(accessory?.id, achievement.id); + } + })); this.renderDisposables.add(card.onDidEscape(() => this.onDidRequestClose())); - cards.set(accessoryId, card); + cards.set(cardId, card); } const roadmapItem = DOM.append(list, DOM.$('li.chat-pet-achievements-list-item')); @@ -226,7 +244,6 @@ export class ChatPetAchievementsWidget extends Disposable { roadmapIntro.textContent = localize('chatPet.achievements.roadmap.intro', "Upcoming pet features:"); const roadmapList = DOM.append(roadmapContent, DOM.$('ul.chat-pet-achievement-roadmap-list')); for (const item of [ - localize('chatPet.achievements.roadmap.namingCompetition', "A naming competition"), localize('chatPet.achievements.roadmap.moreAchievements', "More achievements and built-in hats"), localize('chatPet.achievements.roadmap.customHats', "Customizable hats that you can upload"), ]) { @@ -250,12 +267,12 @@ export class ChatPetAchievementsWidget extends Disposable { } } - private renderCardPreview(previews: HTMLElement, accessory: Parameters[1], unlocked: boolean, variant: ChatPetVariant): void { + private renderCardPreview(previews: HTMLElement, accessory: Parameters[1], unlocked: boolean, color: ChatPetColor): void { const preview = DOM.append(previews, DOM.$('canvas.chat-pet-achievement-preview')) as HTMLCanvasElement; preview.width = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; preview.height = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; preview.setAttribute('aria-hidden', 'true'); - this.renderDisposables.add(renderChatPetAchievementPreview(preview, accessory, unlocked, variant, this.themeService, this.logService)); + this.renderDisposables.add(renderChatPetAchievementPreview(preview, accessory, unlocked, color, this.themeService, this.logService)); } private selectAccessory(accessoryId: ChatPetAccessoryId | undefined, achievementId?: ChatPetAchievementId): void { @@ -276,7 +293,9 @@ export class ChatPetAchievementsWidget extends Disposable { for (const [id, card] of this.accessoryCards) { const wearing = id === selectedId; card.button.element.classList.toggle('wearing', wearing); - card.button.element.setAttribute('aria-pressed', String(wearing)); + if (!card.colorCustomization) { + card.button.element.setAttribute('aria-pressed', String(wearing)); + } const baseAriaLabel = wearing ? card.selectedAriaLabel : card.defaultAriaLabel; card.button.setAriaLabel(card.achievementId && this.unseenAchievementIds.has(card.achievementId) ? localize('chatPet.achievement.cardNewAriaLabel', "{0}. New", baseAriaLabel) diff --git a/src/vs/workbench/contrib/chat/browser/chatPetColors.ts b/src/vs/workbench/contrib/chat/browser/chatPetColors.ts new file mode 100644 index 000000000000..e778cbc4f244 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatPetColors.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../base/browser/dom.js'; +import { Color, RGBA } from '../../../../base/common/color.js'; +import { LinkedMap, Touch } from '../../../../base/common/map.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; + +export const CHAT_PET_CHANGE_COLOR_COMMAND_ID = 'chat.pet.changeColor'; +export const CHAT_PET_BLOBBY_COMMAND_ID = 'chat.pet.blobby'; + +export type ChatPetVariant = 'stable' | 'insiders'; +export type ChatPetColor = ChatPetVariant | `#${string}`; + +export const chatPetColorPresets: readonly { readonly color: ChatPetColor; readonly label: string }[] = [ + { color: 'stable', label: localize('chatPet.color.stable', "Stable") }, + { color: 'insiders', label: localize('chatPet.color.insiders', "Insiders") }, + { color: '#ff8c00', label: localize('chatPet.color.exploration', "Exploration") }, + { color: '#e6536f', label: localize('chatPet.color.red', "Red") }, + { color: '#e6c94d', label: localize('chatPet.color.yellow', "Yellow") }, + { color: '#55b85a', label: localize('chatPet.color.green', "Green") }, + { color: '#a277e6', label: localize('chatPet.color.purple', "Purple") }, + { color: '#ed83b5', label: localize('chatPet.color.pink', "Pink") }, + { color: '#ffffff', label: localize('chatPet.color.white', "White") }, + { color: '#000000', label: localize('chatPet.color.black', "Black") }, +]; + +export function parseChatPetColor(value: string): ChatPetColor | undefined { + if (value === 'stable' || value === 'insiders') { + return value; + } + const hex = value.trim(); + if (!/^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(hex)) { + return undefined; + } + const digits = hex.slice(1).toLowerCase(); + return `#${digits.length === 3 ? [...digits].map(digit => digit + digit).join('') : digits}`; +} + +export function getChatPetColorVariant(color: ChatPetColor): ChatPetVariant { + return color === 'insiders' ? 'insiders' : 'stable'; +} + +export function isDefaultChatPetColor(color: ChatPetColor): color is ChatPetVariant { + return color === 'stable' || color === 'insiders'; +} + +export function getChatPetBodyColor(color: ChatPetColor): string { + return color === 'stable' ? '#23a8f2' : color === 'insiders' ? '#24bfa5' : color; +} + +export function getChatPetEyeColor(color: ChatPetColor): string { + return color !== 'stable' && color !== 'insiders' && Color.fromHex(color).getContrastRatio(Color.fromHex('#212324')) < 3 + ? '#f5f5f5' + : '#191a1b'; +} + +const coloredSprites = new WeakMap(); +const coloredSpriteSources = new LinkedMap(); +const MAX_CACHED_SPRITE_PIXELS = 2 * 1024 * 1024; +let cachedSpritePixels = 0; + +export function setChatPetImageSource(image: HTMLImageElement, source: string): void { + const scheme = URI.parse(source).scheme; + image.removeAttribute('src'); + image.crossOrigin = scheme === Schemas.http || scheme === Schemas.https ? 'anonymous' : null; + image.src = source; +} + +/** Reuses recolored sheets across sprite buffers, with at most 8 MiB of source pixels held strongly. */ +export function getChatPetColoredSprite(image: HTMLImageElement, color: ChatPetColor): HTMLImageElement | HTMLCanvasElement { + if (color === 'stable' || color === 'insiders') { + return image; + } + const source = image.getAttribute('src'); + const cached = coloredSprites.get(image); + if (cached?.source === source && cached.color === color) { + return cached.canvas; + } + const sourceKey = source ? `${source}:${image.naturalWidth}x${image.naturalHeight}:${color}` : undefined; + const sourceCanvas = sourceKey ? coloredSpriteSources.get(sourceKey, Touch.AsNew) : undefined; + if (sourceCanvas) { + coloredSprites.set(image, { source, color, canvas: sourceCanvas }); + return sourceCanvas; + } + const canvas = DOM.$('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Unable to create the Blobby color canvas'); + } + context.imageSmoothingEnabled = false; + context.drawImage(image, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height); + const body = Color.fromHex(color); + const shade = body.darken(0.25).rgba; + const shadow = body.darken(0.5).rgba; + const palette = new Map([ + [0x23a8f2, body.rgba], + [0x24bfa5, body.rgba], + [0x0077b8, shade], + [0x009a7c, shade], + [0x004e7c, shadow], + [0x004538, shadow], + [0x006451, shadow], + [0x006652, shadow], + ]); + const eyeColor = getChatPetEyeColor(color); + if (eyeColor !== '#191a1b') { + const eyes = Color.fromHex(eyeColor).rgba; + palette.set(0x191a1b, eyes); + palette.set(0x212324, eyes); + } + for (let i = 0; i < pixels.data.length; i += 4) { + if (pixels.data[i + 3] === 0) { + continue; + } + const replacement = palette.get((pixels.data[i] << 16) | (pixels.data[i + 1] << 8) | pixels.data[i + 2]); + if (replacement) { + pixels.data[i] = replacement.r; + pixels.data[i + 1] = replacement.g; + pixels.data[i + 2] = replacement.b; + } + } + context.putImageData(pixels, 0, 0); + coloredSprites.set(image, { source, color, canvas }); + const pixelCount = canvas.width * canvas.height; + if (sourceKey && pixelCount <= MAX_CACHED_SPRITE_PIXELS) { + coloredSpriteSources.set(sourceKey, canvas, Touch.AsNew); + cachedSpritePixels += pixelCount; + while (cachedSpritePixels > MAX_CACHED_SPRITE_PIXELS) { + const oldest = coloredSpriteSources.shift(); + if (oldest) { + cachedSpritePixels -= oldest.width * oldest.height; + } + } + } + return canvas; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatPetColorsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatPetColorsWidget.ts new file mode 100644 index 000000000000..73b30cdb5d16 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatPetColorsWidget.ts @@ -0,0 +1,212 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatPetAchievements.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { InputBox, MessageType } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, renderChatPetAchievementPreview } from './chatPetAchievementPreview.js'; +import { ChatPetAchievementIds } from './chatPetAchievements.js'; +import { ChatPetColor, chatPetColorPresets, getChatPetBodyColor, isDefaultChatPetColor, parseChatPetColor } from './chatPetColors.js'; +import { IChatPetService } from './chatPetService.js'; + +export class ChatPetColorsWidget extends Disposable { + + private readonly container: HTMLElement; + private readonly content: HTMLElement; + private readonly scrollable: DomScrollableElement; + private readonly cards = new Map(); + private readonly hexInput: InputBox; + private readonly colorInput: HTMLInputElement; + private readonly applyButton: Button; + private readonly validationMessage: HTMLElement; + private readonly customPreview: HTMLCanvasElement; + private readonly previewDisposable = this._register(new MutableDisposable()); + private customizationUnlocked = false; + private selectedColor: ChatPetColor | undefined; + private previewColor: ChatPetColor | undefined; + + constructor( + parent: HTMLElement, + onDidRequestClose: () => void, + @IChatPetService private readonly chatPetService: IChatPetService, + @IThemeService private readonly themeService: IThemeService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.container = DOM.append(parent, DOM.$('.chat-pet-colors-widget')); + this.content = DOM.$('.chat-pet-achievements-content'); + this.scrollable = this._register(new DomScrollableElement(this.content, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + })); + this.container.appendChild(this.scrollable.getDomNode()); + this._register(DOM.addDisposableListener(this.content, DOM.EventType.SCROLL, () => { + this.scrollable.setScrollPosition({ scrollTop: this.content.scrollTop }); + }, { passive: true })); + this._register(DOM.addDisposableListener(this.container, DOM.EventType.KEY_DOWN, e => { + if (new StandardKeyboardEvent(e).equals(KeyCode.Escape)) { + DOM.EventHelper.stop(e, true); + onDidRequestClose(); + } + })); + + const inner = DOM.append(this.content, DOM.$('.chat-pet-achievements-inner')); + DOM.append(inner, DOM.$('h1')).textContent = localize('chatPet.colors.title', "Color"); + DOM.append(inner, DOM.$('p.chat-pet-achievements-intro')).textContent = localize('chatPet.colors.intro', "A little more you. Pick a classic look or give Blobby a color of its own."); + const list = DOM.append(inner, DOM.$('ul.chat-pet-colors-list', { + 'aria-label': localize('chatPet.colors.presets', "Blobby color presets"), + })); + for (const preset of chatPetColorPresets) { + const item = DOM.append(list, DOM.$('li')); + const button = this._register(new Button(item, { secondary: true })); + button.element.classList.add('chat-pet-color-card'); + button.element.dataset.color = preset.color; + const preview = DOM.append(button.element, DOM.$('canvas.chat-pet-color-preview')); + preview.width = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; + preview.height = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; + preview.setAttribute('aria-hidden', 'true'); + this._register(renderChatPetAchievementPreview(preview, undefined, true, preset.color, themeService, logService)); + DOM.append(button.element, DOM.$('span.chat-pet-color-label')).textContent = preset.label; + const state = DOM.append(button.element, DOM.$('span.chat-pet-color-state')); + this.cards.set(preset.color, { button, state, label: preset.label }); + this._register(button.onDidClick(() => this.selectColor(preset.color))); + this._register(button.onDidEscape(onDidRequestClose)); + } + + const unlockHint = DOM.append(inner, DOM.$('p.chat-pet-colors-unlock-hint')); + unlockHint.textContent = localize('chatPet.colors.unlockHint', "Discover Blobby's True Name to unlock solid colors and custom colors."); + const custom = DOM.append(inner, DOM.$('section.chat-pet-custom-color')); + DOM.append(custom, DOM.$('h2')).textContent = localize('chatPet.colors.custom', "Custom color"); + DOM.append(custom, DOM.$('p')).textContent = localize('chatPet.colors.customDescription', "Choose any color or enter a hex value. Apply it when it feels right."); + const customBody = DOM.append(custom, DOM.$('.chat-pet-custom-color-body')); + this.customPreview = DOM.append(customBody, DOM.$('canvas.chat-pet-color-preview.custom-preview')); + this.customPreview.width = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; + this.customPreview.height = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; + this.customPreview.setAttribute('aria-hidden', 'true'); + const controls = DOM.append(customBody, DOM.$('.chat-pet-custom-color-controls')); + const row = DOM.append(controls, DOM.$('.chat-pet-custom-color-inputs')); + this.colorInput = DOM.append(row, DOM.$('input.chat-pet-color-picker', { + type: 'color', + 'aria-label': localize('chatPet.colors.chooseCustom', "Choose Blobby's custom color"), + })); + this.hexInput = this._register(new InputBox(row, undefined, { + inputBoxStyles: defaultInputBoxStyles, + ariaLabel: localize('chatPet.colors.hex', "Custom hex color"), + placeholder: '#ff8800', + validationOptions: { + validation: value => { + const color = parseChatPetColor(value); + return color && !isDefaultChatPetColor(color) ? null : { + type: MessageType.ERROR, + content: localize('chatPet.color.invalid', "Enter a hex color such as #ff8800 or #f80."), + }; + }, + }, + })); + this.applyButton = this._register(new Button(row, defaultButtonStyles)); + this.applyButton.label = localize('chatPet.colors.apply', "Apply Color"); + this._register(this.applyButton.onDidEscape(onDidRequestClose)); + this.validationMessage = DOM.append(controls, DOM.$('.chat-pet-color-validation', { role: 'status', id: generateUuid() })); + this.hexInput.inputElement.setAttribute('aria-describedby', this.validationMessage.id); + + this._register(DOM.addDisposableListener(this.colorInput, DOM.EventType.INPUT, () => { + this.hexInput.value = this.colorInput.value; + })); + this._register(this.hexInput.onDidChange(() => this.updateDraft())); + this._register(this.applyButton.onDidClick(() => this.applyCustomColor())); + this._register(DOM.addDisposableListener(this.hexInput.inputElement, DOM.EventType.KEY_DOWN, e => { + if (new StandardKeyboardEvent(e).equals(KeyCode.Enter) && this.applyButton.enabled) { + DOM.EventHelper.stop(e, true); + this.applyCustomColor(); + } + })); + this._register(autorun(reader => { + const wasUnlocked = this.customizationUnlocked; + this.customizationUnlocked = chatPetService.unlockedAchievements.read(reader).includes(ChatPetAchievementIds.Blobby); + const selectedColor = chatPetService.color.read(reader); + for (const [color, card] of this.cards) { + const enabled = isDefaultChatPetColor(color) || this.customizationUnlocked; + const selected = color === selectedColor; + card.button.enabled = enabled; + card.button.element.classList.toggle('selected', selected); + card.button.element.setAttribute('aria-pressed', String(selected)); + card.state.textContent = selected + ? localize('chatPet.colors.selected', "Selected") + : enabled ? '\u00a0' : localize('chatPet.colors.locked', "Locked"); + card.button.setAriaLabel(selected + ? localize('chatPet.colors.selectedLabel', "{0}, selected", card.label) + : enabled ? card.label : localize('chatPet.colors.lockedLabel', "{0}, locked. Discover Blobby's True Name to unlock.", card.label)); + } + unlockHint.hidden = this.customizationUnlocked; + this.colorInput.disabled = !this.customizationUnlocked; + this.hexInput.setEnabled(this.customizationUnlocked); + if (this.selectedColor !== selectedColor || wasUnlocked !== this.customizationUnlocked) { + this.selectedColor = selectedColor; + this.hexInput.value = getChatPetBodyColor(selectedColor); + } + this.updateDraft(); + })); + } + + private updateDraft(): void { + const color = parseChatPetColor(this.hexInput.value); + const validColor = color && !isDefaultChatPetColor(color) ? color : undefined; + const selectedColor = this.chatPetService.color.get(); + const draftColor = validColor === getChatPetBodyColor(selectedColor) ? selectedColor : validColor; + this.validationMessage.textContent = validColor ? '' : localize('chatPet.color.invalid', "Enter a hex color such as #ff8800 or #f80."); + this.applyButton.enabled = this.customizationUnlocked && draftColor !== undefined && draftColor !== selectedColor; + if (validColor) { + this.colorInput.value = validColor; + } + if (draftColor && draftColor !== this.previewColor) { + this.previewColor = draftColor; + this.previewDisposable.value = renderChatPetAchievementPreview(this.customPreview, undefined, true, draftColor, this.themeService, this.logService); + } + this.scrollable.scanDomNode(); + } + + private applyCustomColor(): void { + this.updateDraft(); + const color = parseChatPetColor(this.hexInput.value); + if (!color || isDefaultChatPetColor(color)) { + this.hexInput.focus(); + return; + } + if (this.applyButton.enabled) { + this.selectColor(color); + } + } + + private selectColor(color: ChatPetColor): void { + this.chatPetService.setColor(color); + if (!isDefaultChatPetColor(color)) { + this.chatPetService.markAchievementSeen(ChatPetAchievementIds.Blobby); + } + } + + layout(dimension: DOM.Dimension): void { + this.container.classList.toggle('narrow', dimension.width < 560); + this.content.style.width = `${dimension.width}px`; + this.content.style.height = `${dimension.height}px`; + this.scrollable.getDomNode().style.height = `${dimension.height}px`; + this.scrollable.scanDomNode(); + } + + focus(): void { + (this.cards.get(this.chatPetService.color.get())?.button ?? this.cards.get('stable')?.button)?.focus(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatPetService.ts b/src/vs/workbench/contrib/chat/browser/chatPetService.ts index f22440953dc6..cb0d58c21f59 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetService.ts @@ -15,9 +15,13 @@ import product from '../../../../platform/product/common/product.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { allChatPetAchievements, chatPetAchievements, ChatPetAccessoryId, ChatPetAchievementId, ChatPetAchievementIds, getChatPetAchievementForAccessory, isChatPetAccessoryId, isChatPetAchievementEnabled, isChatPetAchievementId } from './chatPetAchievements.js'; +import { ChatPetColor, ChatPetVariant, chatPetColorPresets, isDefaultChatPetColor, parseChatPetColor } from './chatPetColors.js'; + +export type { ChatPetVariant } from './chatPetColors.js'; const CHAT_PET_ENABLED_STORAGE_KEY = 'chat.vscodePet.enabled'; const CHAT_PET_VARIANT_STORAGE_KEY = 'chat.vscodePet.variant'; +const CHAT_PET_COLOR_STORAGE_KEY = 'chat.vscodePet.color'; const CHAT_PET_ON_THE_RUN_STORAGE_KEY = 'chat.vscodePet.onTheRun'; const CHAT_PET_ACCESSORY_STORAGE_KEY = 'chat.vscodePet.accessory'; const CHAT_PET_ACHIEVEMENT_SEEN_STORAGE_PREFIX = 'chat.vscodePet.achievementSeen.'; @@ -29,8 +33,6 @@ const CHAT_PET_SCALE_STORAGE_KEY = 'chat.vscodePet.scale'; const CHAT_PET_HORIZONTAL_POSITION_STORAGE_KEY = 'chat.vscodePet.horizontalPosition'; export const CHAT_PET_DEFAULT_SCALE = 1; -export type ChatPetVariant = 'stable' | 'insiders'; - export const ChatPetContextKeys = { enabled: new RawContextKey('chatPetEnabled', false, localize('chatPet.context.enabled', "Whether the VS Code pet is enabled")), }; @@ -69,7 +71,7 @@ export const IChatPetService = createDecorator('chatPetService' export interface IChatPetService { readonly _serviceBrand: undefined; readonly enabled: IObservable; - readonly variant: IObservable; + readonly color: IObservable; readonly onTheRun: IObservable; readonly scale: IObservable; readonly unlockedAchievements: IObservable; @@ -78,7 +80,7 @@ export interface IChatPetService { readonly onDidUnlockAchievement: Event; readonly horizontalPosition: IObservable; toggle(): boolean; - setVariant(variant: ChatPetVariant): void; + setColor(color: ChatPetColor): void; setOnTheRun(onTheRun: boolean): void; setScale(scale: number): void; resetScale(): void; @@ -95,8 +97,8 @@ export class ChatPetService extends Disposable implements IChatPetService { private readonly _enabled; readonly enabled: IObservable; - private readonly _variant; - readonly variant: IObservable; + private readonly _color; + readonly color: IObservable; private readonly _onTheRun; readonly onTheRun: IObservable; private readonly _scale; @@ -110,6 +112,7 @@ export class ChatPetService extends Disposable implements IChatPetService { private readonly _onDidUnlockAchievement = this._register(new Emitter()); readonly onDidUnlockAchievement = this._onDidUnlockAchievement.event; private lastInvalidStoredAccessory: string | undefined; + private lastInvalidStoredColor: string | undefined; private locallyUnlockingAchievement = false; private readonly _horizontalPosition; readonly horizontalPosition: IObservable; @@ -124,8 +127,8 @@ export class ChatPetService extends Disposable implements IChatPetService { this._migrateAchievementStorage(); this._enabled = observableValue(this, this.storageService.getBoolean(CHAT_PET_ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false)); this.enabled = this._enabled; - this._variant = observableValue(this, getChatPetVariant(this.storageService.get(CHAT_PET_VARIANT_STORAGE_KEY, StorageScope.APPLICATION), product.quality)); - this.variant = this._variant; + this._color = observableValue(this, this._readColor()); + this.color = this._color; this._onTheRun = observableValue(this, this.storageService.getBoolean(CHAT_PET_ON_THE_RUN_STORAGE_KEY, StorageScope.APPLICATION, false)); this.onTheRun = this._onTheRun; this._scale = observableValue(this, getChatPetScale(this.storageService.get(CHAT_PET_SCALE_STORAGE_KEY, StorageScope.APPLICATION))); @@ -143,7 +146,10 @@ export class ChatPetService extends Disposable implements IChatPetService { this._setEnabled(this.storageService.getBoolean(CHAT_PET_ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false)); })); this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, CHAT_PET_VARIANT_STORAGE_KEY, this._store)(() => { - this._variant.set(getChatPetVariant(this.storageService.get(CHAT_PET_VARIANT_STORAGE_KEY, StorageScope.APPLICATION), product.quality), undefined); + this._color.set(this._readColor(), undefined); + })); + this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, CHAT_PET_COLOR_STORAGE_KEY, this._store)(() => { + this._color.set(this._readColor(), undefined); })); this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, CHAT_PET_ON_THE_RUN_STORAGE_KEY, this._store)(() => { this._onTheRun.set(this.storageService.getBoolean(CHAT_PET_ON_THE_RUN_STORAGE_KEY, StorageScope.APPLICATION, false), undefined); @@ -190,12 +196,36 @@ export class ChatPetService extends Disposable implements IChatPetService { this.telemetryService.publicLog2('chatPetEnablement', { enabled, source }); } - setVariant(variant: ChatPetVariant): void { - this._variant.set(variant, undefined); - this.storageService.store(CHAT_PET_VARIANT_STORAGE_KEY, variant, StorageScope.APPLICATION, StorageTarget.USER); - status(variant === 'stable' - ? localize('chatPet.variant.stable', "VS Code pet changed to the Stable colors") - : localize('chatPet.variant.insiders', "VS Code pet changed to the Insiders colors")); + setColor(color: ChatPetColor): void { + const normalizedColor = parseChatPetColor(color); + if (!normalizedColor) { + throw new Error(`Invalid chat pet color: ${color}`); + } + if (!isDefaultChatPetColor(normalizedColor) && !this._unlockedAchievements.get().includes(ChatPetAchievementIds.Blobby)) { + throw new Error(localize('chatPet.color.locked', "Use /blobby in Chat to unlock color customization.")); + } + this._color.set(normalizedColor, undefined); + this.storageService.store(CHAT_PET_COLOR_STORAGE_KEY, normalizedColor, StorageScope.APPLICATION, StorageTarget.USER); + const label = chatPetColorPresets.find(preset => preset.color === normalizedColor)?.label ?? normalizedColor; + status(localize('chatPet.color.changed', "Blobby's color changed to {0}", label)); + } + + private _readColor(): ChatPetColor { + const storedColor = this.storageService.get(CHAT_PET_COLOR_STORAGE_KEY, StorageScope.APPLICATION); + if (storedColor !== undefined) { + const color = parseChatPetColor(storedColor); + if (color && (isDefaultChatPetColor(color) || this._isAchievementStored(ChatPetAchievementIds.Blobby))) { + this.lastInvalidStoredColor = undefined; + return color; + } + if (this.lastInvalidStoredColor !== storedColor) { + this.lastInvalidStoredColor = storedColor; + this.logService.warn(`[ChatPetService] Ignoring invalid or locked stored color: ${storedColor}`); + } + } else { + this.lastInvalidStoredColor = undefined; + } + return getChatPetVariant(this.storageService.get(CHAT_PET_VARIANT_STORAGE_KEY, StorageScope.APPLICATION), product.quality); } setOnTheRun(onTheRun: boolean): void { @@ -281,6 +311,10 @@ export class ChatPetService extends Disposable implements IChatPetService { resetAchievements(): void { this.locallyUnlockingAchievement = true; try { + const storedColor = this.storageService.get(CHAT_PET_COLOR_STORAGE_KEY, StorageScope.APPLICATION); + if (storedColor !== 'stable' && storedColor !== 'insiders') { + this.storageService.remove(CHAT_PET_COLOR_STORAGE_KEY, StorageScope.APPLICATION); + } for (const achievement of allChatPetAchievements) { const key = this._getAchievementStorageKey(achievement.id); this.storageService.remove(key, StorageScope.APPLICATION_SHARED); @@ -401,6 +435,7 @@ export class ChatPetService extends Disposable implements IChatPetService { } this._refreshUnseenAchievementState(); this._refreshSelectedAccessory(); + this._color.set(this._readColor(), undefined); } private _readUnseenAchievements(): readonly ChatPetAchievementId[] { diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 1103794ab7eb..df65a276f501 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -42,6 +42,7 @@ import { IWorkbenchEnvironmentService } from '../../../services/environment/comm import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { AICustomizationManagementCommands, AICustomizationManagementSection } from './aiCustomization/aiCustomizationManagement.js'; import { IChatPetService } from './chatPetService.js'; +import { CHAT_PET_BLOBBY_COMMAND_ID } from './chatPetColors.js'; import { ChatSessionArchiveActionWording, ChatSessionArchiveActionWordingSettingId, getChatSessionArchiveActionWording } from '../../../../platform/chat/common/sessionArchiveActions.js'; export class ChatSlashCommandsContribution extends Disposable { @@ -76,6 +77,18 @@ export class ChatSlashCommandsContribution extends Disposable { }, async () => { chatPetService.toggle(); })); + this._register(slashCommandService.registerSlashCommand({ + command: 'blobby', + detail: nls.localize('blobby', "Show Blobby or open its color customization"), + sortText: 'z3_blobby', + executeImmediately: true, + executeDuringRequest: true, + silent: true, + locations: [ChatAgentLocation.Chat], + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Setup.hidden.negate()), + }, async () => { + await commandService.executeCommand(CHAT_PET_BLOBBY_COMMAND_ID); + })); const clearCommandRegistration = this._register(new MutableDisposable()); const registerClearCommand = () => { const wording = getChatSessionArchiveActionWording(configurationService); diff --git a/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css b/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css index c5d1534a1304..bdb01153a586 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css @@ -4,12 +4,74 @@ *--------------------------------------------------------------------------------------------*/ .chat-pet-achievements-editor, -.chat-pet-achievements-widget { +.chat-pet-achievements-widget, +.chat-pet-colors-widget { width: 100%; height: 100%; overflow: hidden; } +.chat-pet-achievements-editor { + display: flex; + flex-direction: column; +} + +.monaco-workbench .chat-pet-achievements-editor[tabindex='-1']:focus { + outline: none; +} + +.chat-pet-customization-tabs { + display: flex; + flex-shrink: 0; + gap: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size160) var(--vscode-spacing-size40); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); +} + +.chat-pet-customization-tabs .monaco-button { + position: relative; + width: auto; + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size80); + border: 0; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body1); +} + +.chat-pet-customization-tabs .monaco-button[aria-selected='true'] { + color: var(--vscode-foreground); +} + +.chat-pet-customization-tabs .monaco-button[aria-selected='true']::after { + content: ''; + position: absolute; + inset-inline: var(--vscode-spacing-size80); + bottom: calc(-1 * var(--vscode-spacing-size40)); + height: var(--vscode-strokeThickness); + background: var(--vscode-focusBorder); + pointer-events: none; +} + +.chat-pet-customization-tabs .monaco-button:hover { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground); +} + +.chat-pet-customization-tabs .monaco-button:focus:not(:focus-visible) { + outline: none; +} + +.chat-pet-customization-tabs .monaco-button:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.chat-pet-customization-panel { + min-height: 0; + flex: 1; +} + .chat-pet-achievements-content { box-sizing: border-box; min-width: 0; @@ -70,6 +132,174 @@ padding: var(--vscode-spacing-size160) var(--vscode-spacing-size120); } +.chat-pet-colors-widget.narrow .chat-pet-achievements-content { + padding: var(--vscode-spacing-size160) var(--vscode-spacing-size120); +} + +.chat-pet-colors-widget .chat-pet-achievements-intro { + margin-bottom: var(--vscode-spacing-size200); +} + +.chat-pet-colors-list { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--vscode-spacing-size120); + list-style: none; + margin: 0 0 var(--vscode-spacing-size240); + padding: 0; +} + +.chat-pet-colors-widget.narrow .chat-pet-colors-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.chat-pet-color-card.monaco-button { + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + width: 100%; + min-width: 0; + gap: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-size80); + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-small); + background: var(--vscode-editorWidget-background); + color: var(--vscode-foreground); + font-size: var(--vscode-fontSize-label1); + text-align: center; +} + +.chat-pet-color-card.monaco-button:not(.disabled), +.chat-pet-achievement-card.monaco-button:not(.disabled) { + cursor: pointer; +} + +.chat-pet-color-card.monaco-button.selected { + border-color: var(--vscode-focusBorder); + background: var(--vscode-list-inactiveSelectionBackground); +} + +.chat-pet-color-card.monaco-button:not(.disabled):hover { + background: var(--vscode-list-hoverBackground); +} + +.chat-pet-color-card.monaco-button:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.chat-pet-color-preview { + width: 64px; + height: 64px; + image-rendering: pixelated; + flex-shrink: 0; +} + +.chat-pet-color-label { + overflow-wrap: anywhere; + white-space: normal; + font-weight: var(--vscode-fontWeight-semiBold); +} + +.chat-pet-color-state { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); +} + +.chat-pet-colors-unlock-hint, +.chat-pet-custom-color p { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body1); +} + +.chat-pet-custom-color { + padding: var(--vscode-spacing-size160); + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-medium); + background: var(--vscode-editorWidget-background); +} + +.chat-pet-custom-color h2 { + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); + margin-bottom: var(--vscode-spacing-size80); +} + +.chat-pet-custom-color-body, +.chat-pet-custom-color-inputs { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size120); +} + +.chat-pet-custom-color-controls { + flex: 1; + min-width: 0; +} + +.chat-pet-custom-color-inputs { + flex-wrap: wrap; +} + +.chat-pet-custom-color-inputs .monaco-inputbox { + width: 120px; +} + +.chat-pet-custom-color-inputs .monaco-inputbox input { + cursor: text; +} + +.chat-pet-custom-color-inputs .monaco-inputbox input:disabled { + cursor: default; +} + +.chat-pet-custom-color-inputs .monaco-button { + width: auto; +} + +.chat-pet-color-picker { + box-sizing: border-box; + width: 32px; + height: 28px; + padding: var(--vscode-spacing-size20); + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-editorWidget-border)); + border-radius: var(--vscode-cornerRadius-small); + background: var(--vscode-input-background); + cursor: pointer; +} + +.chat-pet-color-picker:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + +.chat-pet-color-picker:disabled { + cursor: default; +} + +.chat-pet-color-validation { + color: var(--vscode-errorForeground); + font-size: var(--vscode-fontSize-label1); + margin-top: var(--vscode-spacing-size80); +} + +.chat-pet-color-validation:empty { + display: none; +} + +.hc-black .chat-pet-color-card.monaco-button, +.hc-light .chat-pet-color-card.monaco-button, +.hc-black .chat-pet-custom-color, +.hc-light .chat-pet-custom-color { + border-color: var(--vscode-contrastBorder); +} + +.hc-black .chat-pet-color-card.monaco-button.selected, +.hc-light .chat-pet-color-card.monaco-button.selected { + border-color: var(--vscode-focusBorder); +} + .chat-pet-achievements-widget.narrow .chat-pet-achievements-list { grid-template-columns: minmax(0, 1fr); } @@ -101,6 +331,7 @@ border-radius: var(--vscode-cornerRadius-medium); background: var(--vscode-editorWidget-background); color: var(--vscode-foreground); + cursor: default; } .chat-pet-achievement-roadmap-preview { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts index d64df84b3158..ca8da4bb26a8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts @@ -5,6 +5,7 @@ import { FileAccess } from '../../../../../base/common/network.js'; import type { IChatPetAccessory } from '../chatPetAchievements.js'; +import { ChatPetColor, getChatPetColoredSprite } from '../chatPetColors.js'; import type { ChatPetState } from './chatPetWidget.js'; import { ChatPetAccessoryRigPose, getChatPetAccessoryRigFrame, getChatPetAntennaeOcclusionBounds, getChatPetEyeAccessoryAnchor } from './chatPetAccessoryRig.js'; @@ -62,13 +63,15 @@ export function drawChatPetComposite( includeEyeAccessory = true, eyeAccessoryMirrorsWithFacing = true, coversAntennae = false, + color?: ChatPetColor, ): void { context.clearRect(0, 0, frameWidth, frameHeight); const sourceX = bodyFrameIndex * frameWidth; + const coloredBody = color ? getChatPetColoredSprite(bodyImage, color) : bodyImage; const rigFrame = getChatPetAccessoryRigFrame(state, rigFrameIndex); if (fixedOrientationDecorations !== undefined && facingDirection === 'left') { - drawMirroredChatPetLayer(context, bodyImage, sourceX, 0, frameWidth, frameHeight); - drawFixedOrientationDecorations(context, bodyImage, bodyFrameIndex, frameWidth, fixedOrientationDecorations); + drawMirroredChatPetLayer(context, coloredBody, sourceX, 0, frameWidth, frameHeight); + drawFixedOrientationDecorations(context, coloredBody, bodyFrameIndex, frameWidth, fixedOrientationDecorations); if (accessoryImage) { if (coversAntennae) { clearChatPetAntennae(context, state, rigFrameIndex, frameWidth, true); @@ -91,7 +94,7 @@ export function drawChatPetComposite( if (accessoryImage) { drawTrackedAccessoryLayer(context, accessoryImage, rigFrame, 'back', frameWidth); } - context.drawImage(bodyImage, sourceX, 0, frameWidth, frameHeight, 0, 0, frameWidth, frameHeight); + context.drawImage(coloredBody, sourceX, 0, frameWidth, frameHeight, 0, 0, frameWidth, frameHeight); if (accessoryImage) { if (coversAntennae) { clearChatPetAntennae(context, state, rigFrameIndex, frameWidth, false); @@ -135,7 +138,7 @@ export function drawChatPetEyeAccessory(context: CanvasRenderingContext2D, acces } : anchor); } -function drawMirroredChatPetLayer(context: CanvasRenderingContext2D, image: HTMLImageElement, sourceX: number, sourceY: number, frameWidth: number, frameHeight: number): void { +function drawMirroredChatPetLayer(context: CanvasRenderingContext2D, image: CanvasImageSource, sourceX: number, sourceY: number, frameWidth: number, frameHeight: number): void { context.save(); context.translate(frameWidth, 0); context.scale(-1, 1); @@ -152,7 +155,7 @@ function clearChatPetAntennae(context: CanvasRenderingContext2D, state: ChatPetS context.clearRect(x, bounds.y, bounds.width, bounds.height); } -function drawFixedOrientationDecorations(context: CanvasRenderingContext2D, bodyImage: HTMLImageElement, frameIndex: number, frameWidth: number, decorations: readonly IChatPetFixedOrientationDecoration[]): void { +function drawFixedOrientationDecorations(context: CanvasRenderingContext2D, bodyImage: CanvasImageSource, frameIndex: number, frameWidth: number, decorations: readonly IChatPetFixedOrientationDecoration[]): void { for (const decoration of decorations) { const currentBounds = decoration.frameBounds[frameIndex]; const canonicalBounds = decoration.frameBounds[decoration.sourceFrame]; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts index a08e999457cc..3ce9e46d6ef3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts @@ -28,6 +28,7 @@ import { IHostService } from '../../../../services/host/browser/host.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, ChatPetAccessoryId, getChatPetAccessory, getChatPetAchievement } from '../chatPetAchievements.js'; import { CHAT_PET_DEFAULT_SCALE, ChatPetVariant, IChatPetService } from '../chatPetService.js'; +import { CHAT_PET_CHANGE_COLOR_COMMAND_ID, ChatPetColor, getChatPetBodyColor, getChatPetColoredSprite, getChatPetColorVariant, getChatPetEyeColor, setChatPetImageSource } from '../chatPetColors.js'; import { drawChatPetComposite, drawChatPetEyeAccessory, getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions, IChatPetAccessoryImageSource, IChatPetFixedOrientationDecoration } from './chatPetAccessoryRenderer.js'; import { getChatPetAccessoryRigFrame, getChatPetReducedMotionRigFrame } from './chatPetAccessoryRig.js'; @@ -176,8 +177,10 @@ interface ChatPetSpriteElement { readonly image: HTMLImageElement; readonly accessoryImages?: readonly HTMLImageElement[]; readonly canvas: HTMLCanvasElement; + color: ChatPetColor; activeAccessory?: ChatPetAccessoryId; activeAccessoryImage?: HTMLImageElement; + redrawFrame?: () => void; } interface ChatPetPendingRender { @@ -408,7 +411,7 @@ export function getChatPetRespawnFrameDurations(): readonly number[] { return RESPAWN_FRAME_DURATIONS; } -function getSpriteSources(variant: ChatPetVariant): Record { +export function getChatPetSpriteSources(variant: ChatPetVariant): Record { let sources = spriteSources.get(variant); if (!sources) { const createStateSpriteSources = (state: ChatPetState) => createSpriteSources(getChatPetSpriteName(state, variant), state, doesChatPetStateTrackCursor(state)); @@ -496,8 +499,8 @@ function doesChatPetStateSpeak(state: ChatPetState | undefined): boolean { return state === 'rendering' || state === 'achievementUnlocked'; } -export function drawChatPetAchievementStar(context: CanvasRenderingContext2D, variant: ChatPetVariant): void { - context.fillStyle = variant === 'stable' ? 'rgb(35, 168, 242)' : 'rgb(36, 191, 165)'; +export function drawChatPetAchievementStar(context: CanvasRenderingContext2D, color: ChatPetColor): void { + context.fillStyle = getChatPetBodyColor(color); context.fillRect(56, 34, 24, 24); context.fillStyle = 'rgb(255, 205, 15)'; const rows = ['..#..', '.###.', '#####', '.#.#.', '#...#']; @@ -1322,6 +1325,7 @@ export class ChatPetWidget extends Disposable { private _respawnPosition: readonly [number, number] | undefined; private readonly _resizeObserver: dom.DisposableResizeObserver; private _variant: ChatPetVariant; + private _color: ChatPetColor; private _selectedAccessory: ChatPetAccessoryId | undefined; private _scale = 1; @@ -1341,7 +1345,8 @@ export class ChatPetWidget extends Disposable { this.dragBounds = host.dragBounds; this.movementBounds = host.movementBounds; this._host = observableValue(this, host); - this._variant = this.chatPetService.variant.get(); + this._color = this.chatPetService.color.get(); + this._variant = getChatPetColorVariant(this._color); this._selectedAccessory = this.chatPetService.selectedAccessory.get(); this.parent.classList.add('chat-pet-host'); this._overlay = dom.$(`.${CHAT_PET_OVERLAY_CLASS}`); @@ -1372,7 +1377,7 @@ export class ChatPetWidget extends Disposable { const respawnEffectImage = dom.append(this._overlay, dom.$('img.chat-pet-spritesheet')) as HTMLImageElement; respawnEffectImage.alt = ''; respawnEffectImage.setAttribute('aria-hidden', 'true'); - this._respawnEffect = { container: respawnEffectCanvas, image: respawnEffectImage, canvas: respawnEffectCanvas }; + this._respawnEffect = { container: respawnEffectCanvas, image: respawnEffectImage, canvas: respawnEffectCanvas, color: this._color }; this._register(dom.addDisposableListener(respawnEffectImage, 'load', () => this._startRespawnEffectAnimation())); this._register(dom.addDisposableListener(respawnEffectImage, 'error', () => { this.logService.error(`[ChatPetWidget] Failed to load respawn sprite: ${respawnEffectImage.getAttribute('src')}`); @@ -1397,7 +1402,7 @@ export class ChatPetWidget extends Disposable { accessoryImage.setAttribute('aria-hidden', 'true'); return accessoryImage; }); - const sprite: ChatPetSpriteElement = { container, image, accessoryImages, canvas }; + const sprite: ChatPetSpriteElement = { container, image, accessoryImages, canvas, color: this._color }; this._register(dom.addDisposableListener(image, 'load', () => this._onBodyImageLoad(sprite))); this._register(dom.addDisposableListener(image, 'error', () => this._onBodyImageError(sprite))); for (const accessoryImage of accessoryImages) { @@ -1407,6 +1412,7 @@ export class ChatPetWidget extends Disposable { return sprite; }); this._eyes = dom.append(this._visual, dom.$('.chat-pet-eyes')); + this._eyes.style.color = getChatPetEyeColor(this._color); this._eyes.setAttribute('aria-hidden', 'true'); for (const side of ['left', 'right']) { const eye = dom.append(this._eyes, dom.$(`.chat-pet-eye.${side}`)); @@ -1428,7 +1434,7 @@ export class ChatPetWidget extends Disposable { const speechBubbleImage = dom.append(speechBubbleContainer, dom.$('img.chat-pet-spritesheet')) as HTMLImageElement; speechBubbleImage.alt = ''; speechBubbleImage.setAttribute('aria-hidden', 'true'); - this._speechBubble = { container: speechBubbleContainer, image: speechBubbleImage, canvas: speechBubbleCanvas }; + this._speechBubble = { container: speechBubbleContainer, image: speechBubbleImage, canvas: speechBubbleCanvas, color: this._color }; this._resizeObserver = this._register(new dom.DisposableResizeObserver('ChatPetWidget.dragBounds', () => this._handleHostLayoutChange(), dom.getWindow(this._button.element), { resizeObserverCtor })); this._observeHost(host); if (this._getHorizontalBounds() !== undefined) { @@ -1609,9 +1615,21 @@ export class ChatPetWidget extends Disposable { this._setScale(scale); } const enabled = isChatPetVisible(serviceEnabled, isWindowActive); - const variant = this.chatPetService.variant.read(reader); + const color = this.chatPetService.color.read(reader); + const colorChanged = color !== this._color; + this._color = color; + const variant = getChatPetColorVariant(color); const variantChanged = variant !== this._variant; this._variant = variant; + if (colorChanged && (!variantChanged || color.startsWith('#'))) { + this._eyes.style.color = getChatPetEyeColor(color); + for (const sprite of [this._activeSprite, this._speechBubble, this._respawnEffect]) { + if (sprite) { + sprite.color = color; + sprite.redrawFrame?.(); + } + } + } const selectedAccessory = this.chatPetService.selectedAccessory.read(reader); const accessoryChanged = selectedAccessory !== this._selectedAccessory; this._selectedAccessory = selectedAccessory; @@ -2480,10 +2498,13 @@ export class ChatPetWidget extends Disposable { true, () => this.commandService.executeCommand(CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID) )); - const stable = actions.add(new Action('chat.pet.variant.stable', localize('chatPet.variant.stable.action', "Stable Colors"), undefined, true, () => this.chatPetService.setVariant('stable'))); - stable.checked = this.chatPetService.variant.get() === 'stable'; - const insiders = actions.add(new Action('chat.pet.variant.insiders', localize('chatPet.variant.insiders.action', "Insiders Colors"), undefined, true, () => this.chatPetService.setVariant('insiders'))); - insiders.checked = this.chatPetService.variant.get() === 'insiders'; + const changeColor = actions.add(new Action( + CHAT_PET_CHANGE_COLOR_COMMAND_ID, + localize('chatPet.changeColor.action', "Change Color"), + undefined, + true, + () => this.commandService.executeCommand(CHAT_PET_CHANGE_COLOR_COMMAND_ID), + )); const grow = actions.add(new Action('chat.pet.grow', localize('chatPet.grow.action', "Grow"), undefined, true, () => { const scale = getChatPetScale(this._scale, CHAT_PET_SCALE_STEP); this.chatPetService.setScale(scale); @@ -2520,8 +2541,7 @@ export class ChatPetWidget extends Disposable { shrink, resetSize, appearanceSeparator, - stable, - insiders, + changeColor, ], onHide: () => { this._contextMenuVisible = false; @@ -2803,8 +2823,7 @@ export class ChatPetWidget extends Disposable { const source = this._motionReduced ? sources.reducedMotion : sources.animated; if (!isChatPetImageSource(this._respawnEffect.image, source.url)) { this._respawnAnimation.clear(); - this._respawnEffect.image.removeAttribute('src'); - this._respawnEffect.image.src = source.url; + setChatPetImageSource(this._respawnEffect.image, source.url); return; } if (this._respawnEffect.image.complete && this._respawnEffect.image.naturalWidth > 0) { @@ -3213,7 +3232,7 @@ export class ChatPetWidget extends Disposable { if (state !== 'idle' || useStaticSprite) { this._facingController.setState(state, useStaticSprite); } - const sources = getSpriteSources(this._variant)[state]; + const sources = getChatPetSpriteSources(this._variant)[state]; const source = this._motionReduced || useStaticSprite ? sources.reducedMotion : sources.animated; if (!restart && this._activeSprite && isChatPetImageSource(this._activeSprite.image, source.url)) { this._pendingRender = undefined; @@ -3249,11 +3268,9 @@ export class ChatPetWidget extends Disposable { state, useStaticSprite, }; - sprite.image.removeAttribute('src'); - sprite.image.src = source.url; + setChatPetImageSource(sprite.image, source.url); if (accessoryImage && accessorySource && accessoryImage !== cachedAccessoryImage) { - accessoryImage.removeAttribute('src'); - accessoryImage.src = accessorySource.url; + setChatPetImageSource(accessoryImage, accessorySource.url); } } @@ -3417,8 +3434,7 @@ export class ChatPetWidget extends Disposable { return; } this._pendingAccessorySwitch = { generation, sprite, source, image, accessory }; - image.removeAttribute('src'); - image.src = source.url; + setChatPetImageSource(image, source.url); } private _tryCompleteAccessorySwitch(): void { @@ -3514,6 +3530,7 @@ export class ChatPetWidget extends Disposable { } private _startSpriteAnimation(source: ChatPetSpriteSource, sprite: ChatPetSpriteElement, animationDisposable: MutableDisposable, onComplete?: () => void, reverse = false, onFrame?: (frameIndex: number) => void, state?: ChatPetState): void { + sprite.color = this._color; const { frameDurations } = source; const { image, canvas } = sprite; const displaySize = sprite === this._speechBubble ? 72 : sprite === this._respawnEffect ? this._getDisplaySize() : CHAT_PET_DISPLAY_SIZE; @@ -3532,7 +3549,9 @@ export class ChatPetWidget extends Disposable { return; } context.imageSmoothingEnabled = false; + let renderedFrameIndex = 0; const drawFrame = (frameIndex: number) => { + renderedFrameIndex = frameIndex; if (state) { const activeAccessory = sprite.activeAccessory ? getChatPetAccessory(sprite.activeAccessory) : undefined; drawChatPetComposite( @@ -3549,18 +3568,25 @@ export class ChatPetWidget extends Disposable { false, activeAccessory?.eyeAccessoryMirrorsWithFacing !== false, activeAccessory?.coversAntennae === true, + sprite.color, ); this._drawEyeAccessory(sprite.activeAccessory, sprite.activeAccessoryImage, source, state, source.accessoryRigFrame ?? frameIndex); } else { context.clearRect(0, 0, source.frameWidth, frameHeight); - context.drawImage(image, frameIndex * source.frameWidth, 0, source.frameWidth, frameHeight, 0, 0, source.frameWidth, frameHeight); + context.drawImage(getChatPetColoredSprite(image, sprite.color), frameIndex * source.frameWidth, 0, source.frameWidth, frameHeight, 0, 0, source.frameWidth, frameHeight); } if (sprite === this._activeSprite) { this._activeFrameIndex = frameIndex; } onFrame?.(frameIndex); }; + sprite.redrawFrame = () => { + if (image.complete && image.naturalWidth > 0 && isChatPetImageSource(image, source.url)) { + drawFrame(renderedFrameIndex); + } + }; if (sprite === this._activeSprite) { + this._eyes.style.color = getChatPetEyeColor(sprite.color); this._redrawActiveFrame = () => drawFrame(this._activeFrameIndex); this._redrawEyeAccessory = state ? () => this._drawEyeAccessory(sprite.activeAccessory, sprite.activeAccessoryImage, source, state, source.accessoryRigFrame ?? this._activeFrameIndex) @@ -3679,8 +3705,7 @@ export class ChatPetWidget extends Disposable { const source = this._motionReduced ? sources.reducedMotion : sources.animated; if (!isChatPetImageSource(this._speechBubble.image, source.url)) { this._speechAnimation.clear(); - this._speechBubble.image.removeAttribute('src'); - this._speechBubble.image.src = source.url; + setChatPetImageSource(this._speechBubble.image, source.url); return; } if ((restart || stateChanged) && this._speechBubble.image.complete && this._speechBubble.image.naturalWidth > 0) { @@ -3694,7 +3719,7 @@ export class ChatPetWidget extends Disposable { state === 'achievementUnlocked' ? () => { const context = this._speechBubble.canvas.getContext('2d'); if (context) { - drawChatPetAchievementStar(context, this._variant); + drawChatPetAchievementStar(context, this._speechBubble.color); } } : undefined, ); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css index 80810ee05936..c862e3fe3324 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css @@ -337,6 +337,7 @@ } .chat-pet-eyes { + color: #191a1b; position: absolute; inset: 0; visibility: hidden; @@ -386,7 +387,7 @@ left: 2px; width: 4px; height: 4px; - background: #191a1b; + background: currentColor; } .chat-pet-button[data-state='idle'] .chat-pet-eye, @@ -445,7 +446,7 @@ left: 0; width: 8px; height: 2px; - background: #191a1b; + background: currentColor; } .chat-pet-button.dragging .chat-pet-eye::before { diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index 044066999636..b2435d997081 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -258,17 +258,19 @@ suite('Chat Accessibility Help', () => { assert.deepStrictEqual({ keybinding: helpText.includes(''), navigation: helpText.includes('use the up and down arrow keys to choose'), - actions: helpText.includes('Go on the Run') && helpText.includes('Grow') && helpText.includes('Shrink') && helpText.includes('Reset Size') && helpText.includes('Stable Colors') && helpText.includes('Insiders Colors'), + actions: helpText.includes('Go on the Run') && helpText.includes('Grow') && helpText.includes('Shrink') && helpText.includes('Reset Size') && helpText.includes('Change Color'), + colorCustomization: helpText.includes('True Name') && helpText.includes('Stable and Insiders are free choices') && helpText.includes('Apply Color') && helpText.includes('Unapplied custom colors are discarded'), petMovement: helpText.includes('Drag it around the chat with the mouse') && helpText.includes('left and right arrows to make it hop'), petHopping: helpText.includes('make it hop along the input until it reaches an edge'), petThrowing: helpText.includes('flick it in any direction') && helpText.includes('gravity pulls it down') && helpText.includes('Hold Shift with the left or right arrow to throw it toward a wall'), petBouncing: helpText.includes('Pointer collisions are ignored for half a second after a drag release') && helpText.includes('while the pet is falling, move the pointer into it to bounce it upward') && helpText.includes('Sideways and upward travel do not start the bounce counter') && helpText.includes('counter beside the pet tracks consecutive bounces and remains for up to five seconds after landing') && helpText.includes('until the pet next reacts or interacts') && helpText.includes('at least twenty bounces triggers confetti unless reduced motion is enabled') && helpText.includes('press Enter or Space to bounce it upward'), petRevival: helpText.includes('a despawn effect appears at the bottom') && helpText.includes('a respawn effect appears at the top') && helpText.includes('automatically returns to the input'), - petScale: helpText.includes('position and selected size are shared across chats and windows') && helpText.includes('remembered after you restart'), + petScale: helpText.includes('position, size, and color are shared across chats and windows') && helpText.includes('remembered after you restart'), }, { keybinding: true, navigation: true, actions: true, + colorCustomization: true, petMovement: true, petHopping: true, petThrowing: true, diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts index b99f30042c3d..15734817ceb0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts @@ -4,26 +4,48 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; +import { Dimension } from '../../../../../base/browser/dom.js'; import { mainWindow } from '../../../../../base/browser/window.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { FileAccess, Schemas } from '../../../../../base/common/network.js'; import { toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; -import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { EditorInputCapabilities } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; +import { TestChatEntitlementService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, ChatPetAccessoryId, ChatPetAccessoryIds, ChatPetAchievementId, ChatPetAchievementIds } from '../../browser/chatPetAchievements.js'; import '../../browser/chatPetAchievements.contribution.js'; import { ChatPetAchievementsEditorInput } from '../../browser/chatPetAchievementsEditorInput.js'; +import { ChatPetAchievementsEditor } from '../../browser/chatPetAchievementsEditor.js'; import { ChatPetAchievementsWidget } from '../../browser/chatPetAchievementsWidget.js'; -import { ChatPetVariant, IChatPetService } from '../../browser/chatPetService.js'; +import { ChatPetService, ChatPetVariant, IChatPetService } from '../../browser/chatPetService.js'; +import { CHAT_PET_CHANGE_COLOR_COMMAND_ID, getChatPetBodyColor } from '../../browser/chatPetColors.js'; suite('Chat Pet Achievements Editor', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + setup(() => { + if (mainWindow.location.protocol === `${Schemas.file}:`) { + // Keep preview assets on the file: origin of the Electron test document. + sinon.stub(FileAccess, 'asBrowserUri').callsFake(resource => FileAccess.asFileUri(resource)); + } + }); + teardown(() => sinon.restore()); + test('opens a standalone modal editor input', async () => { let openedInput: ChatPetAchievementsEditorInput | undefined; let pinned: boolean | undefined; @@ -38,10 +60,13 @@ suite('Chat Pet Achievements Editor', () => { override readonly enabled = constObservable(true); }(); const accessor = { - get: (service: typeof IEditorService | typeof IChatPetService) => { + get: (service: typeof IEditorService | typeof IChatPetService | typeof IChatEntitlementService) => { if (service === IChatPetService) { return chatPetService; } + if (service === IChatEntitlementService) { + return new TestChatEntitlementService(); + } assert.strictEqual(service, IEditorService); return editorService; }, @@ -59,7 +84,7 @@ suite('Chat Pet Achievements Editor', () => { requiresModal: openedInput.hasCapability(EditorInputCapabilities.RequiresModal), modalOptions: openedInput.getModalEditorOptions(), }, { - name: 'Achievements', + name: 'Customize Blobby', pinned: true, singleton: true, requiresModal: true, @@ -98,7 +123,7 @@ suite('Chat Pet Achievements Editor', () => { override readonly unlockedAchievements = constObservable([]); override readonly unseenAchievements = constObservable([]); override readonly selectedAccessory = constObservable(undefined); - override readonly variant = constObservable('stable'); + override readonly color = constObservable('stable'); }(); store.add(new ChatPetAchievementsWidget( parent, @@ -106,6 +131,7 @@ suite('Chat Pet Achievements Editor', () => { chatPetService, new TestThemeService(), store.add(new NullLogService()), + new class extends mock() { }(), )); const lockedCard = parent.querySelector(`[data-accessory-id="${ChatPetAccessoryIds.TopHatMonocle}"]`); @@ -157,7 +183,7 @@ suite('Chat Pet Achievements Editor', () => { ]); override readonly unseenAchievements = constObservable([]); override readonly selectedAccessory = selectedAccessory; - override readonly variant = constObservable('stable'); + override readonly color = constObservable('stable'); override markAchievementSeen(): boolean { return false; @@ -174,6 +200,7 @@ suite('Chat Pet Achievements Editor', () => { chatPetService, new TestThemeService(), store.add(new NullLogService()), + new class extends mock() { }(), )); const unlockedCards = Array.from(parent.querySelectorAll('.chat-pet-achievement-card.monaco-button:not(.locked)')); @@ -183,6 +210,10 @@ suite('Chat Pet Achievements Editor', () => { assert.deepStrictEqual({ unlockedCardIds: unlockedCards.map(card => card.dataset.accessoryId), + unlockedCardCursors: unlockedCards.map(card => mainWindow.getComputedStyle(card).cursor), + lockedCardCursors: [...new Set(Array.from(parent.querySelectorAll('.chat-pet-achievement-card.locked')).map(card => mainWindow.getComputedStyle(card).cursor))], + contentCursors: Array.from(bambooHatCard.querySelectorAll('h3, p, canvas')).map(element => mainWindow.getComputedStyle(element).cursor), + roadmapCursor: mainWindow.getComputedStyle(parent.querySelector('.chat-pet-achievement-roadmap')!).cursor, firstMessageTitleCount: Array.from(parent.querySelectorAll('h3')).filter(title => title.textContent === 'Welcome to the Wild West').length, trustButVerifyTitleCount: Array.from(parent.querySelectorAll('h3')).filter(title => title.textContent === 'Trust but Verify').length, selected, @@ -195,6 +226,10 @@ suite('Chat Pet Achievements Editor', () => { ChatPetAccessoryIds.CowboyHat, ChatPetAccessoryIds.BambooHat, ], + unlockedCardCursors: ['pointer', 'pointer', 'pointer'], + lockedCardCursors: ['default'], + contentCursors: ['pointer', 'pointer', 'pointer', 'pointer'], + roadmapCursor: 'default', firstMessageTitleCount: 1, trustButVerifyTitleCount: 1, selected: ChatPetAccessoryIds.BambooHat, @@ -214,7 +249,7 @@ suite('Chat Pet Achievements Editor', () => { override readonly unlockedAchievements = constObservable([ChatPetAchievementIds.FirstChatMessage]); override readonly unseenAchievements = constObservable([]); override readonly selectedAccessory = constObservable(undefined); - override readonly variant = constObservable('stable'); + override readonly color = constObservable('stable'); }(); const widget = store.add(new ChatPetAchievementsWidget( parent, @@ -222,6 +257,7 @@ suite('Chat Pet Achievements Editor', () => { chatPetService, new TestThemeService(), store.add(new NullLogService()), + new class extends mock() { }(), )); const noHatCard = parent.querySelector('[data-accessory-id="none"]'); @@ -234,4 +270,131 @@ suite('Chat Pet Achievements Editor', () => { assert.strictEqual(closeCount, 2); }); + + test('unlocks a keyboard-accessible color reward without changing the selected hat', async () => { + const parent = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(parent); + store.add(toDisposable(() => parent.remove())); + const service = store.add(new ChatPetService(store.add(new TestStorageService()), new NullTelemetryServiceShape(), new NullLogService())); + service.toggle(); + service.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + service.setAccessory(ChatPetAccessoryIds.CowboyHat); + const commands: string[] = []; + store.add(new ChatPetAchievementsWidget( + parent, + () => { }, + service, + new TestThemeService(), + new NullLogService(), + new class extends mock() { + override async executeCommand(id: string): Promise { + commands.push(id); + return undefined; + } + }(), + )); + const locked = parent.querySelector('[data-achievement-id="blobby"]'); + const lockedState = { title: locked?.querySelector('h3')?.textContent, disabled: locked?.getAttribute('aria-disabled') }; + service.unlockAchievement(ChatPetAchievementIds.Blobby); + const card = parent.querySelector('[data-achievement-id="blobby"]'); + assert.ok(card); + card.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + await Promise.resolve(); + assert.deepStrictEqual({ + lockedState, + commands, + label: card.getAttribute('aria-label'), + pressed: card.getAttribute('aria-pressed'), + hat: service.selectedAccessory.get(), + unseen: service.unseenAchievements.get().includes(ChatPetAchievementIds.Blobby), + }, { + lockedState: { title: 'Locked', disabled: 'true' }, + commands: [CHAT_PET_CHANGE_COLOR_COMMAND_ID], + label: 'True Name. Reward: Color Customization. Change Color', + pressed: null, + hat: ChatPetAccessoryIds.CowboyHat, + unseen: false, + }); + }); + + test('navigates between Achievements and Color with editor options and accessible tab keys', async () => { + const parent = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(parent); + store.add(toDisposable(() => parent.remove())); + const storage = store.add(new TestStorageService()); + const service = store.add(new ChatPetService(storage, new NullTelemetryServiceShape(), new NullLogService())); + service.toggle(); + const theme = new TestThemeService(); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IChatPetService, service); + instantiationService.stub(IThemeService, theme); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ICommandService, new class extends mock() { }()); + const entitlement = new TestChatEntitlementService(); + let closeCount = 0; + const editor = store.add(new ChatPetAchievementsEditor( + new class extends mock() { + override windowId = mainWindow.vscodeWindowId; + override async closeEditor(): Promise { closeCount++; return true; } + }(), + new NullTelemetryServiceShape(), + theme, + storage, + instantiationService, + store.add(new ContextKeyService(new TestConfigurationService())), + service, + entitlement, + )); + editor.create(parent); + editor.layout(new Dimension(800, 600)); + await editor.setInput(store.add(ChatPetAchievementsEditorInput.getOrCreate()), { tab: 'color' }, {}, CancellationToken.None); + const colorTab = parent.querySelector('[role="tab"][aria-selected="true"]'); + assert.ok(colorTab); + editor.focus(); + const page = parent.querySelector('.chat-pet-achievements-editor'); + assert.deepStrictEqual({ + pageFocused: mainWindow.document.activeElement === page, + tabFocused: colorTab === mainWindow.document.activeElement, + role: page?.getAttribute('role'), + label: page?.getAttribute('aria-label'), + selectedTabReachable: colorTab.tabIndex, + }, { + pageFocused: true, + tabFocused: false, + role: 'group', + label: 'Customize Blobby', + selectedTabReachable: 0, + }); + const initialTab = colorTab.textContent; + colorTab.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', keyCode: 37, bubbles: true })); + const afterLeft = parent.querySelector('[role="tab"][aria-selected="true"]')?.textContent; + const activeTab = parent.querySelector('[role="tab"][aria-selected="true"]'); + activeTab?.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', keyCode: 35, bubbles: true })); + const afterEnd = parent.querySelector('[role="tab"][aria-selected="true"]')?.textContent; + colorTab.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', keyCode: 35, bubbles: true })); + const repeatedEnd = parent.querySelector('[role="tab"][aria-selected="true"]')?.textContent; + editor.setOptions({ tab: 'achievements' }); + assert.deepStrictEqual({ + initialTab, + afterLeft, + afterEnd, + repeatedEnd, + afterOptions: parent.querySelector('[role="tab"][aria-selected="true"]')?.textContent, + tabStops: Array.from(parent.querySelectorAll('[role="tab"]')).map(tab => tab.tabIndex), + visiblePanels: parent.querySelectorAll('[role="tabpanel"]:not([hidden])').length, + colorsDisposed: parent.querySelector('.chat-pet-colors-widget') === null, + }, { initialTab: 'Color', afterLeft: 'Achievements', afterEnd: 'Color', repeatedEnd: 'Color', afterOptions: 'Achievements', tabStops: [0, -1], visiblePanels: 1, colorsDisposed: true }); + + service.unlockAchievement(ChatPetAchievementIds.Blobby); + editor.setOptions({ tab: 'color' }); + const customInput = parent.querySelector('input[aria-label="Custom hex color"]'); + assert.ok(customInput); + customInput.value = '#123456'; + customInput.dispatchEvent(new Event('input', { bubbles: true })); + editor.clearInput(); + await editor.setInput(ChatPetAchievementsEditorInput.getOrCreate(), { tab: 'color' }, {}, CancellationToken.None); + assert.strictEqual(parent.querySelector('input[aria-label="Custom hex color"]')?.value, getChatPetBodyColor(service.color.get())); + entitlement.sentimentObs.set({ hidden: true }, undefined); + assert.strictEqual(closeCount, 1, 'The open customization editor closes when AI features are hidden'); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts new file mode 100644 index 000000000000..120e9248c6cd --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts @@ -0,0 +1,680 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import sinon from 'sinon'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { Color } from '../../../../../base/common/color.js'; +import { FileAccess, Schemas } from '../../../../../base/common/network.js'; +import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { TestChatEntitlementService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; +import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, ChatPetAchievementIds, getChatPetAchievement, getChatPetAchievementPresentation } from '../../browser/chatPetAchievements.js'; +import '../../browser/chatPetAchievements.contribution.js'; +import { CHAT_PET_BLOBBY_COMMAND_ID, CHAT_PET_CHANGE_COLOR_COMMAND_ID, ChatPetColor, chatPetColorPresets, getChatPetBodyColor, getChatPetColoredSprite, getChatPetEyeColor, parseChatPetColor, setChatPetImageSource } from '../../browser/chatPetColors.js'; +import { ChatPetContextKeys, ChatPetService, IChatPetService } from '../../browser/chatPetService.js'; +import { ChatPetAchievementsEditorInput, IChatPetCustomizationEditorOptions } from '../../browser/chatPetAchievementsEditorInput.js'; +import { ChatPetColorsWidget } from '../../browser/chatPetColorsWidget.js'; +import { IChatWidgetService } from '../../browser/chat.js'; +import { IAgentHostSessionWorkingDirectoryResolver } from '../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; +import { IAgentHostUntitledProvisionalSessionService } from '../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { IAgentSessionsService } from '../../browser/agentSessions/agentSessionsService.js'; +import { ChatSlashCommandsContribution } from '../../browser/chatSlashCommands.js'; +import { getChatPetSpriteSources } from '../../browser/widget/chatPetWidget.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { IChatAgentService } from '../../common/participants/chatAgents.js'; +import { IChatSlashCommandService, IChatSlashData } from '../../common/participants/chatSlashCommands.js'; + +suite('Chat Pet Colors', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + setup(() => { + if (mainWindow.location.protocol === `${Schemas.file}:`) { + // Keep preview assets on the file: origin of the Electron test document. + sinon.stub(FileAccess, 'asBrowserUri').callsFake(resource => FileAccess.asFileUri(resource)); + } + }); + teardown(() => sinon.restore()); + + function createService(storage = store.add(new TestStorageService())) { + return store.add(new ChatPetService(storage, new NullTelemetryServiceShape(), new NullLogService())); + } + + function createCommandHarness() { + const service = createService(); + const instantiationService = store.add(new TestInstantiationService()); + const entitlement = new class extends TestChatEntitlementService { + override readonly sentiment = { hidden: false }; + }(); + instantiationService.stub(IChatEntitlementService, entitlement); + instantiationService.stub(IChatPetService, service); + instantiationService.stub(IEditorService, new class extends mock() { }()); + const opened: IChatPetCustomizationEditorOptions[] = []; + instantiationService.stub(IEditorService, 'openEditor', async (input: ChatPetAchievementsEditorInput, options: IChatPetCustomizationEditorOptions) => { + store.add(input); + opened.push(options); + return undefined; + }); + const run = async (id: string) => { + const command = CommandsRegistry.getCommand(id); + assert.ok(command, id); + await instantiationService.invokeFunction(accessor => command.handler(accessor)); + }; + instantiationService.stub(ICommandService, new class extends mock() { + override async executeCommand(id: string): Promise { + await run(id); + return undefined; + } + }()); + return { service, opened, run, entitlement }; + } + + function createColorWidget(unlocked = false) { + const parent = DOM.$('div'); + mainWindow.document.body.append(parent); + store.add(toDisposable(() => parent.remove())); + const service = createService(); + service.toggle(); + service.setColor('stable'); + if (unlocked) { + service.unlockAchievement(ChatPetAchievementIds.Blobby); + } + let closeCount = 0; + const widget = store.add(new ChatPetColorsWidget(parent, () => closeCount++, service, new TestThemeService(), new NullLogService())); + widget.layout(new DOM.Dimension(800, 500)); + const input = parent.querySelector('input[aria-label="Custom hex color"]'); + const picker = parent.querySelector('input[type="color"]'); + const apply = parent.querySelector('.chat-pet-custom-color-inputs .monaco-button'); + assert.ok(input && picker && apply); + const setInput = (value: string) => { + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + }; + return { parent, service, widget, input, picker, apply, setInput, getCloseCount: () => closeCount }; + } + + async function loadImage(url: string): Promise { + const image = DOM.$('img'); + setImageSource(image, url); + await image.decode(); + return image; + } + + function setImageSource(image: HTMLImageElement, url: string): void { + // Electron's test document uses file:, unlike the workbench's vscode-file: origin. + image.src = mainWindow.location.protocol === `${Schemas.file}:` ? FileAccess.uriToFileUri(URI.parse(url)).toString(true) : url; + } + + function getPixels(source: HTMLImageElement | HTMLCanvasElement): ImageData { + const canvas = DOM.$('canvas'); + canvas.width = source.width; + canvas.height = source.height; + const context = canvas.getContext('2d'); + assert.ok(context); + context.drawImage(source, 0, 0); + return context.getImageData(0, 0, canvas.width, canvas.height); + } + + test('accepts opaque hex colors and presets, normalizing shorthand', () => { + assert.deepStrictEqual( + ['stable', 'insiders', '#F80', ' #AbC123 ', '#000000', '#ffffff', '#1234', '#12345678', '#ggg', 'red', '', 'transparent', 'rgb(1, 2, 3)'].map(parseChatPetColor), + ['stable', 'insiders', '#ff8800', '#abc123', '#000000', '#ffffff', undefined, undefined, undefined, undefined, undefined, undefined, undefined], + ); + assert.ok(chatPetColorPresets.every(preset => parseChatPetColor(preset.color) === preset.color)); + }); + + test('uses the official Exploration orange instead of a generic orange preset', () => { + assert.deepStrictEqual({ + exploration: chatPetColorPresets.find(preset => preset.label === 'Exploration'), + firstRow: chatPetColorPresets.slice(0, 5).map(preset => preset.label), + hasGenericOrange: chatPetColorPresets.some(preset => preset.label === 'Orange'), + previousOrangeRemainsValid: parseChatPetColor('#ed9844'), + }, { + exploration: { color: '#ff8c00', label: 'Exploration' }, + firstRow: ['Stable', 'Insiders', 'Exploration', 'Red', 'Yellow'], + hasGenericOrange: false, + previousOrangeRemainsValid: '#ed9844', + }); + }); + + test('preserves legacy appearance and keeps Stable and Insiders free', () => { + const storage = store.add(new TestStorageService()); + storage.store('chat.vscodePet.variant', 'stable', StorageScope.APPLICATION, StorageTarget.USER); + const service = createService(storage); + assert.strictEqual(service.color.get(), 'stable'); + service.setColor('insiders'); + assert.strictEqual(createService(storage).color.get(), 'insiders'); + service.resetAchievements(); + assert.strictEqual(service.color.get(), 'insiders'); + service.setColor('stable'); + assert.throws(() => service.setColor('#ff8800'), /Use \/blobby/); + service.toggle(); + assert.throws(() => service.setColor('#ff8800'), /Use \/blobby/); + service.unlockAchievement(ChatPetAchievementIds.Blobby); + assert.throws(() => service.setColor('#invalid'), /Invalid chat pet color/); + }); + + test('persists, synchronizes, and resets custom colors independently of hats', () => { + const storage = store.add(new TestStorageService()); + storage.store('chat.vscodePet.variant', 'stable', StorageScope.APPLICATION, StorageTarget.USER); + const service = createService(storage); + service.toggle(); + service.unlockAchievement(ChatPetAchievementIds.Blobby); + service.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + service.setAccessory('cowboyHat'); + service.setColor('#F80'); + const restored = createService(storage); + const persisted = { + color: restored.color.get(), + stored: storage.get('chat.vscodePet.color', StorageScope.APPLICATION), + accessory: restored.selectedAccessory.get(), + }; + service.setColor('insiders'); + const preset = restored.color.get(); + service.setColor('#abc123'); + service.toggle(); + const disabled = restored.color.get(); + service.resetAchievements(); + assert.deepStrictEqual({ + persisted, + preset, + disabled, + reset: restored.color.get(), + unlocked: restored.unlockedAchievements.get(), + storedAfterReset: storage.get('chat.vscodePet.color', StorageScope.APPLICATION), + }, { + persisted: { color: '#ff8800', stored: '#ff8800', accessory: 'cowboyHat' }, + preset: 'insiders', + disabled: '#abc123', + reset: 'stable', + unlocked: [], + storedAfterReset: undefined, + }); + }); + + test('reports invalid or locked stored colors instead of rendering them', () => { + const storage = store.add(new TestStorageService()); + storage.store('chat.vscodePet.variant', 'insiders', StorageScope.APPLICATION, StorageTarget.USER); + storage.store('chat.vscodePet.color', '#ff8800', StorageScope.APPLICATION, StorageTarget.USER); + const warnings: string[] = []; + const service = store.add(new ChatPetService(storage, new NullTelemetryServiceShape(), new class extends NullLogService { + override warn(message: string): void { warnings.push(message); } + }())); + const locked = service.color.get(); + service.toggle(); + service.unlockAchievement(ChatPetAchievementIds.Blobby); + const unlocked = service.color.get(); + storage.store('chat.vscodePet.color', '#broken', StorageScope.APPLICATION, StorageTarget.USER); + assert.deepStrictEqual({ + locked, + unlocked, + invalid: service.color.get(), + warningCount: warnings.length, + }, { locked: 'insiders', unlocked: '#ff8800', invalid: 'insiders', warningCount: 2 }); + }); + + test('invoking Blobby turns on an inactive pet without opening the page, then opens Color while active', async () => { + const { service, opened, run } = createCommandHarness(); + const unlocks: string[] = []; + store.add(service.onDidUnlockAchievement(id => unlocks.push(id))); + await run(CHAT_PET_BLOBBY_COMMAND_ID); + const firstInvocation = { enabled: service.enabled.get(), opened: opened.length, unlocks: [...unlocks] }; + await run(CHAT_PET_BLOBBY_COMMAND_ID); + await run(CHAT_PET_BLOBBY_COMMAND_ID); + assert.deepStrictEqual({ + firstInvocation, + enabled: service.enabled.get(), + unlocks, + opened, + }, { + firstInvocation: { enabled: true, opened: 0, unlocks: [ChatPetAchievementIds.Blobby] }, + enabled: true, + unlocks: [ChatPetAchievementIds.Blobby], + opened: [{ pinned: true, tab: 'color' }, { pinned: true, tab: 'color' }], + }); + }); + + test('Change Color opens the color page before earning True Name and does not alter the color', async () => { + const { service, opened, run } = createCommandHarness(); + await run(CHAT_PET_CHANGE_COLOR_COMMAND_ID); + assert.deepStrictEqual(opened, []); + service.toggle(); + service.setColor('stable'); + await run(CHAT_PET_CHANGE_COLOR_COMMAND_ID); + assert.deepStrictEqual({ + opened, + color: service.color.get(), + unlocked: service.unlockedAchievements.get(), + }, { + opened: [{ pinned: true, tab: 'color' }], + color: 'stable', + unlocked: [], + }); + }); + + test('pet customization commands respect the AI opt-out even when invoked directly', async () => { + const { service, opened, run, entitlement } = createCommandHarness(); + entitlement.sentiment.hidden = true; + await run(CHAT_PET_BLOBBY_COMMAND_ID); + const activated = service.enabled.get(); + service.toggle(); + for (const command of [CHAT_PET_BLOBBY_COMMAND_ID, CHAT_PET_CHANGE_COLOR_COMMAND_ID, CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID]) { + await run(command); + } + assert.deepStrictEqual({ activated, unlocked: service.unlockedAchievements.get(), opened }, { activated: false, unlocked: [], opened: [] }); + }); + + test('workbench slash command and Command Palette availability respect the AI opt-out independently of agent registration', () => { + const slashCommands: IChatSlashData[] = []; + const configuration = new TestConfigurationService(); + store.add(new ChatSlashCommandsContribution( + new class extends mock() { + override registerSlashCommand(data: IChatSlashData) { + slashCommands.push(data); + return Disposable.None; + } + }(), + new class extends mock() { }(), + new class extends mock() { }(), + store.add(new TestInstantiationService()), + new class extends mock() { }(), + new class extends mock() { }(), + configuration, + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + createService(), + new class extends mock() { override readonly isSessionsWindow = false; }(), + )); + const blobby = slashCommands.find(command => command.command === 'blobby'); + const changeColor = MenuRegistry.getCommand(CHAT_PET_CHANGE_COLOR_COMMAND_ID); + assert.ok(blobby?.when && changeColor?.precondition); + const context = store.add(new ContextKeyService(configuration)); + const chatEnabled = ChatContextKeys.enabled.bindTo(context); + const chatHidden = ChatContextKeys.Setup.hidden.bindTo(context); + const petEnabled = ChatPetContextKeys.enabled.bindTo(context); + const cases = [ + { enabled: true, hidden: false, pet: true }, + { enabled: true, hidden: true, pet: true }, + { enabled: false, hidden: false, pet: true }, + { enabled: true, hidden: false, pet: false }, + { enabled: true, hidden: false, pet: true }, + ]; + assert.deepStrictEqual(cases.map(value => { + chatEnabled.set(value.enabled); + chatHidden.set(value.hidden); + petEnabled.set(value.pet); + return { + slash: context.contextMatchesRules(blobby.when), + palette: context.contextMatchesRules(changeColor.precondition), + }; + }), [ + { slash: true, palette: true }, + { slash: false, palette: false }, + { slash: false, palette: false }, + { slash: true, palette: false }, + { slash: true, palette: true }, + ]); + }); + + test('represents color customization as an achievement without inventing a hat', () => { + const achievement = getChatPetAchievement(ChatPetAchievementIds.Blobby); + const unlocked = getChatPetAchievementPresentation(achievement, true); + const locked = getChatPetAchievementPresentation(achievement, false); + assert.deepStrictEqual({ + accessories: achievement.accessories, + title: achievement.title, + description: achievement.description, + colorCustomization: achievement.colorCustomization, + unlockedRewards: unlocked.rewardLabels, + lockedRewards: locked.rewardLabels, + lockedRevealsTitle: JSON.stringify(locked).includes('True Name'), + }, { + accessories: [], + title: 'True Name', + description: 'Blob the Builder? Blobby McBlobface? Nope, my name is Blobby.', + colorCustomization: true, + unlockedRewards: ['Color Customization'], + lockedRewards: ['Color Customization'], + lockedRevealsTitle: false, + }); + }); + + test('shows compact preset cards with only Stable and Insiders available before True Name', () => { + const { parent, service, input, picker } = createColorWidget(); + const cards = Array.from(parent.querySelectorAll('.chat-pet-color-card')); + const freeCards = cards.filter(card => card.getAttribute('aria-disabled') !== 'true').map(card => card.dataset.color); + cards.find(card => card.dataset.color === 'insiders')?.click(); + const selectedPreset = service.color.get(); + const initiallyDisabled = input.disabled && picker.disabled; + service.unlockAchievement(ChatPetAchievementIds.Blobby); + assert.deepStrictEqual({ + count: cards.length, + freeCards, + selectedPreset, + initiallyDisabled, + unlockedCardCount: cards.filter(card => card.getAttribute('aria-disabled') !== 'true').length, + customEnabled: !input.disabled && !picker.disabled, + previewSizes: [...new Set(cards.map(card => card.querySelector('canvas')?.getBoundingClientRect().width))], + }, { + count: 10, + freeCards: ['stable', 'insiders'], + selectedPreset: 'insiders', + initiallyDisabled: true, + unlockedCardCount: 10, + customEnabled: true, + previewSizes: [64], + }); + }); + + test('uses theme colors to distinguish the selected preset card', () => { + const { parent, service } = createColorWidget(true); + parent.style.setProperty('--vscode-focusBorder', '#0088ff'); + parent.style.setProperty('--vscode-editorWidget-border', '#666666'); + parent.style.setProperty('--vscode-list-inactiveSelectionBackground', '#333333'); + service.setColor('#ed83b5'); + const selected = parent.querySelector('.chat-pet-color-card.selected'); + assert.ok(selected); + const styles = mainWindow.getComputedStyle(selected); + assert.deepStrictEqual({ + label: selected.getAttribute('aria-label'), + border: styles.borderTopColor, + background: styles.backgroundColor, + }, { label: 'Pink, selected', border: 'rgb(0, 136, 255)', background: 'rgb(51, 51, 51)' }); + }); + + test('cursors distinguish clickable color controls from disabled controls and text input', () => { + const { parent, service, input, picker, apply, setInput } = createColorWidget(); + const cursors = (selector: string) => Array.from(parent.querySelectorAll(selector)).map(element => mainWindow.getComputedStyle(element).cursor); + const locked = { + freeCard: cursors('[data-color="stable"], [data-color="stable"] > *'), + lockedCard: cursors('[data-color="#e6536f"], [data-color="#e6536f"] > *'), + hex: mainWindow.getComputedStyle(input).cursor, + picker: mainWindow.getComputedStyle(picker).cursor, + apply: mainWindow.getComputedStyle(apply).cursor, + }; + service.unlockAchievement(ChatPetAchievementIds.Blobby); + setInput('#123abc'); + assert.deepStrictEqual({ + locked, + unlocked: { + colorCard: cursors('[data-color="#e6536f"], [data-color="#e6536f"] > *'), + hex: mainWindow.getComputedStyle(input).cursor, + picker: mainWindow.getComputedStyle(picker).cursor, + apply: mainWindow.getComputedStyle(apply).cursor, + }, + }, { + locked: { + freeCard: ['pointer', 'pointer', 'pointer', 'pointer'], + lockedCard: ['default', 'default', 'default', 'default'], + hex: 'default', + picker: 'default', + apply: 'default', + }, + unlocked: { + colorCard: ['pointer', 'pointer', 'pointer', 'pointer'], + hex: 'text', + picker: 'pointer', + apply: 'pointer', + }, + }); + }); + + test('previews a custom color without applying it and validates hex input inline', () => { + const { parent, service, setInput, input, apply } = createColorWidget(true); + setInput('#F80'); + const previewOnly = service.color.get(); + setInput('#xyz'); + const invalid = { + message: parent.querySelector('.chat-pet-color-validation')?.textContent, + ariaInvalid: input.getAttribute('aria-invalid'), + disabled: apply.getAttribute('aria-disabled'), + }; + apply.click(); + const afterInvalidApply = service.color.get(); + setInput('#F80'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + assert.deepStrictEqual({ + previewOnly, + invalid, + afterInvalidApply, + applied: service.color.get(), + normalizedInput: input.value, + }, { + previewOnly: 'stable', + invalid: { message: 'Enter a hex color such as #ff8800 or #f80.', ariaInvalid: 'true', disabled: 'true' }, + afterInvalidApply: 'stable', + applied: '#ff8800', + normalizedInput: '#ff8800', + }); + }); + + test('uses the color picker and preserves the selected hat when applying custom colors', () => { + const { service, picker, input, apply } = createColorWidget(true); + service.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + service.setAccessory('cowboyHat'); + picker.value = '#123456'; + picker.dispatchEvent(new Event('input', { bubbles: true })); + const beforeApply = service.color.get(); + apply.click(); + assert.deepStrictEqual({ + beforeApply, + input: input.value, + color: service.color.get(), + hat: service.selectedAccessory.get(), + seen: !service.unseenAchievements.get().includes(ChatPetAchievementIds.Blobby), + }, { beforeApply: 'stable', input: '#123456', color: '#123456', hat: 'cowboyHat', seen: true }); + }); + + test('Escape closes the color page without applying the custom draft', () => { + const { service, input, setInput, getCloseCount } = createColorWidget(true); + setInput('#aa33cc'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + assert.deepStrictEqual({ closed: getCloseCount(), color: service.color.get() }, { closed: 1, color: 'stable' }); + }); + + test('Escape on color cards and Apply closes the page even when the buttons are disabled', () => { + const { parent, service, getCloseCount } = createColorWidget(); + const buttons = Array.from(parent.querySelectorAll('.monaco-button')); + for (const button of buttons) { + button.focus(); + button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + } + assert.deepStrictEqual({ closed: getCloseCount(), color: service.color.get() }, { closed: buttons.length, color: 'stable' }); + }); + + test('unrelated achievements do not discard an unapplied color draft', () => { + const { service, input, picker, apply, setInput } = createColorWidget(true); + setInput('#123abc'); + service.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + assert.deepStrictEqual({ + draft: input.value, + picker: picker.value, + applyEnabled: apply.getAttribute('aria-disabled'), + selected: service.color.get(), + }, { + draft: '#123abc', + picker: '#123abc', + applyEnabled: 'false', + selected: 'stable', + }); + }); + + test('an unchanged default color draft does not replace the original palette', () => { + const { service, input, apply } = createColorWidget(true); + const results = []; + for (const color of ['stable', 'insiders'] as const) { + service.setColor(color); + const disabled = apply.getAttribute('aria-disabled'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + apply.click(); + results.push({ color: service.color.get(), disabled }); + } + assert.deepStrictEqual(results, [{ color: 'stable', disabled: 'true' }, { color: 'insiders', disabled: 'true' }]); + }); + + test('updates the scroll range when custom color validation adds content', () => { + const { parent, widget, setInput } = createColorWidget(true); + widget.layout(new DOM.Dimension(240, 240)); + const slider = parent.querySelector('.scrollbar.vertical .slider'); + const content = parent.querySelector('.chat-pet-achievements-content'); + assert.ok(slider && content); + const before = { slider: Number.parseFloat(slider.style.height), scrollHeight: content.scrollHeight, clientHeight: content.clientHeight }; + setInput('#invalid'); + const after = { slider: Number.parseFloat(slider.style.height), scrollHeight: content.scrollHeight, clientHeight: content.clientHeight }; + assert.ok(after.slider < before.slider, JSON.stringify({ before, after })); + }); + + test('refreshes color cards after an external change and relocks custom controls when achievements reset', () => { + const { service, parent, input, picker, apply } = createColorWidget(true); + service.setColor('#ed83b5'); + const selected = parent.querySelector('.chat-pet-color-card.selected')?.getAttribute('data-color'); + service.resetAchievements(); + assert.deepStrictEqual({ + selected, + locked: input.disabled && picker.disabled && apply.getAttribute('aria-disabled') === 'true', + freeCards: Array.from(parent.querySelectorAll('.chat-pet-color-card')).filter(card => card.getAttribute('aria-disabled') !== 'true').map(card => card.dataset.color), + }, { selected: '#ed83b5', locked: true, freeCards: ['stable', 'insiders'] }); + }); + + test('recolors every animated and reduced-motion source without changing alpha, eyes, or props', async () => { + const urls = new Set(Object.values(getChatPetSpriteSources('stable')).flatMap(sources => [sources.animated.url, sources.reducedMotion.url])); + for (const effect of ['speech', 'respawn']) { + for (const suffix of ['', '.spritesheet']) { + urls.add(FileAccess.asBrowserUri(`vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-${effect}-stable-96${suffix}.png`).toString(true)); + } + } + const bodyColors = new Set([0x23a8f2, 0x0077b8, 0x004e7c]); + const failures: string[] = []; + for (const url of urls) { + const image = await loadImage(url); + const before = getPixels(image); + const colored = getChatPetColoredSprite(image, '#cc44aa'); + const after = getPixels(colored); + if (before.width !== after.width || before.height !== after.height) { + failures.push(`Dimensions changed: ${url}`); + } + let changed = 0; + for (let i = 0; i < before.data.length; i += 4) { + const rgb = (before.data[i] << 16) | (before.data[i + 1] << 8) | before.data[i + 2]; + if (before.data[i + 3] !== after.data[i + 3]) { + failures.push(`Alpha changed at ${i}: ${url}`); + break; + } + if (!bodyColors.has(rgb)) { + if (before.data[i] !== after.data[i] || before.data[i + 1] !== after.data[i + 1] || before.data[i + 2] !== after.data[i + 2]) { + failures.push(`Non-body pixel changed at ${i}: ${url}`); + break; + } + } else if (rgb === 0x23a8f2 && before.data[i + 3] !== 0) { + changed++; + if (after.data[i] !== 204 || after.data[i + 1] !== 68 || after.data[i + 2] !== 170) { + failures.push(`Incorrect custom color at ${i}: ${url}`); + break; + } + } + } + if (changed === 0) { + failures.push(`No body pixels tested: ${url}`); + } + } + assert.deepStrictEqual(failures, []); + }); + + test('caches recolored sheets and leaves original presets untouched', async () => { + const image = await loadImage(getChatPetSpriteSources('stable').idle.reducedMotion.url); + const first = getChatPetColoredSprite(image, '#ff8800'); + assert.strictEqual(getChatPetColoredSprite(image, '#ff8800'), first); + const second = getChatPetColoredSprite(image, '#00ff00'); + assert.notStrictEqual(second, first); + assert.strictEqual(getChatPetColoredSprite(image, 'stable'), image); + assert.strictEqual(getChatPetColoredSprite(image, 'insiders'), image); + setImageSource(image, getChatPetSpriteSources('stable').sleep.reducedMotion.url); + await image.decode(); + assert.notStrictEqual(getChatPetColoredSprite(image, '#00ff00'), second); + }); + + test('network sprite sources request anonymous CORS without applying it to local schemes', () => { + const image = DOM.$('img'); + const requests: { source: string; crossOrigin: string | null }[] = []; + sinon.stub(image, 'src').set((source: string) => requests.push({ source, crossOrigin: image.crossOrigin })); + const sources = [ + 'https://vscode-cdn.example/sprite.png', + 'http://localhost/sprite.png', + 'vscode-file://vscode-app/sprite.png', + 'file:///sprite.png', + 'data:image/png;base64,', + ]; + for (const source of sources) { + setChatPetImageSource(image, source); + } + assert.deepStrictEqual(requests, sources.map((source, index) => ({ source, crossOrigin: index < 2 ? 'anonymous' : null }))); + }); + + test('reuses recolored sheets between image buffers and when returning to a previous state', async () => { + const idle = getChatPetSpriteSources('stable').idle.animated.url; + const first = await loadImage(idle); + const second = await loadImage(idle); + const colored = getChatPetColoredSprite(first, '#ef1234'); + const fromSecondBuffer = getChatPetColoredSprite(second, '#ef1234'); + setImageSource(first, getChatPetSpriteSources('stable').sleep.animated.url); + await first.decode(); + getChatPetColoredSprite(first, '#ef1234'); + setImageSource(first, idle); + await first.decode(); + assert.ok(fromSecondBuffer === colored && getChatPetColoredSprite(first, '#ef1234') === colored); + }); + + test('bounds shared recolor memory while preserving each active image cache', async () => { + const source = getChatPetSpriteSources('stable').idle.animated.url; + const image = await loadImage(source); + const firstColor = '#120001'; + const first = getChatPetColoredSprite(image, firstColor); + const temporary = await loadImage(source); + for (const color of ['#120002', '#120003', '#120004', '#120005'] as const) { + getChatPetColoredSprite(temporary, color); + } + const another = await loadImage(source); + assert.deepStrictEqual({ + activePreserved: getChatPetColoredSprite(image, firstColor) === first, + oldSourceEvicted: getChatPetColoredSprite(another, firstColor) !== first, + }, { activePreserved: true, oldSourceEvicted: true }); + }); + + test('keeps baked and runtime eyes readable on black and other dark colors', async () => { + const colors: readonly ChatPetColor[] = [...chatPetColorPresets.map(preset => preset.color), '#191a1b', '#000080', '#004400']; + assert.ok(colors.every(color => Color.fromHex(getChatPetEyeColor(color)).getContrastRatio(Color.fromHex(getChatPetBodyColor(color))) >= 3)); + const image = await loadImage(FileAccess.asBrowserUri('vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-idle-stable-96.png').toString(true)); + const before = getPixels(image).data; + const after = getPixels(getChatPetColoredSprite(image, '#000000')).data; + const eyePixels: number[][] = []; + for (let i = 0; i < before.length; i += 4) { + if (before[i] === 25 && before[i + 1] === 26 && before[i + 2] === 27 && before[i + 3] === 255) { + eyePixels.push(Array.from(after.slice(i, i + 4))); + } + } + assert.deepStrictEqual(eyePixels, Array.from({ length: 256 }, () => [245, 245, 245, 255])); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index 26ff801ce906..c13da9730027 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -10,6 +10,7 @@ import { mainWindow } from '../../../../../../base/browser/window.js'; import { timeout } from '../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { FileAccess, Schemas } from '../../../../../../base/common/network.js'; import { constObservable } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -24,6 +25,7 @@ import { IHostService } from '../../../../../services/host/browser/host.js'; import { IChatModel } from '../../../common/model/chatModel.js'; import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, chatPetAchievements, ChatPetAccessoryIds, ChatPetAchievementIds, didExplicitlyEnableChatPetAutopilot, disabledChatPetAchievements, getChatPetAchievement, getChatPetAchievementPresentation, getChatPetCustomizationAchievementIds, getUnlockedChatPetAccessories, isUserAuthoredChatPetCustomization, shouldUnlockChatPetIntegratedBrowserShare } from '../../../browser/chatPetAchievements.js'; import { ChatPetService, getChatPetVariant } from '../../../browser/chatPetService.js'; +import { CHAT_PET_CHANGE_COLOR_COMMAND_ID, ChatPetColor, getChatPetColoredSprite } from '../../../browser/chatPetColors.js'; import '../../../browser/widget/media/chat.css'; import { getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from '../../../browser/widget/chatPetAccessoryRenderer.js'; import { getChatPetAccessoryRigFrame, getChatPetAccessoryRigPose, getChatPetAccessoryTrack, getChatPetAntennaeOcclusionBounds, getChatPetEyeAccessoryAnchor, getChatPetReducedMotionRigFrame } from '../../../browser/widget/chatPetAccessoryRig.js'; @@ -33,6 +35,11 @@ suite('ChatPetWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + setup(() => { + if (mainWindow.location.protocol === `${Schemas.file}:`) { + sinon.stub(FileAccess, 'asBrowserUri').callsFake(resource => FileAccess.asFileUri(resource)); + } + }); teardown(() => sinon.restore()); class TestTelemetryService extends NullTelemetryServiceShape { @@ -74,7 +81,7 @@ suite('ChatPetWidget', () => { }; } - function createHostTransitionHarness(motionReduced = false, initialTransition: IChatPetWidgetHost['transition'] = 'teleport') { + function createHostTransitionHarness(motionReduced = false, initialTransition: IChatPetWidgetHost['transition'] = 'teleport', color?: ChatPetColor) { const root = mainWindow.document.createElement('div'); root.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:640px'; const firstParent = mainWindow.document.createElement('div'); @@ -113,6 +120,10 @@ suite('ChatPetWidget', () => { }; const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); service.toggle(); + if (color) { + service.unlockAchievement(ChatPetAchievementIds.Blobby); + service.setColor(color); + } const widget = disposables.add(new ChatPetWidget( firstHost, TestResizeObserver, service, accessibilityService, new class extends mock() { }(), @@ -152,6 +163,40 @@ suite('ChatPetWidget', () => { : []); } + for (const reducedMotion of [false, true]) { + test(`live color changes preserve the current frame and double buffering (${reducedMotion ? 'reduced motion' : 'animated'})`, async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + try { + const { button, service } = createHostTransitionHarness(reducedMotion, 'teleport', '#ff8800'); + const firstImage = button.querySelector('.chat-pet-sprite img[src]')!; + await firstImage.decode(); + await Promise.resolve(); + clock.tick(800); + const activeCanvas = button.querySelector('.chat-pet-sprite:not(.hidden) canvas')!; + const expected = mainWindow.document.createElement('canvas'); + expected.width = expected.height = 96; + const expectedContext = expected.getContext('2d')!; + service.setColor('#12abcd'); + expectedContext.drawImage(getChatPetColoredSprite(firstImage, '#12abcd'), (reducedMotion ? 0 : 20) * 96, 0, 96, 96, 0, 0, 96, 96); + assert.strictEqual(activeCanvas.toDataURL(), expected.toDataURL(), 'Recolor the current frame rather than restarting at frame zero'); + const previousFrame = activeCanvas.toDataURL(); + service.setColor('insiders'); + assert.strictEqual(activeCanvas.toDataURL(), previousFrame, 'Keep the previous composite until the new variant loads'); + const insiders = button.querySelector('.chat-pet-sprite.hidden img[src*="insiders"]')!; + await insiders.decode(); + await Promise.resolve(); + assert.ok(insiders.parentElement?.classList.contains('hidden') === false); + service.setColor('#12abcd'); + const immediateCanvas = insiders.parentElement!.querySelector('canvas')!; + expectedContext.clearRect(0, 0, 96, 96); + expectedContext.drawImage(getChatPetColoredSprite(insiders, '#12abcd'), 0, 0, 96, 96, 0, 0, 96, 96); + assert.strictEqual(immediateCanvas.toDataURL(), expected.toDataURL(), 'Recolor the currently displayed Insiders sheet while Stable loads'); + } finally { + clock.restore(); + } + }); + } + test('runs one timed hop for a single key press', () => { const clock = sinon.useFakeTimers(); const { controller, events } = createHopHarness(); @@ -305,6 +350,63 @@ suite('ChatPetWidget', () => { }); }); + test('always offers Change Color without revealing the slash command', async () => { + const parent = mainWindow.document.createElement('div'); + mainWindow.document.body.append(parent); + disposables.add(toDisposable(() => parent.remove())); + let menu: IContextMenuDelegate | undefined; + const commands: string[] = []; + const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); + service.toggle(); + disposables.add(new ChatPetWidget( + createPetHost(parent, parent, parent), + undefined, + service, + new TestAccessibilityService(), + new class extends mock() { + override showContextMenu(delegate: IContextMenuDelegate): void { menu = delegate; } + }(), + new class extends mock() { + override async executeCommand(id: string): Promise { + commands.push(id); + return undefined; + } + }(), + new NullLogService(), + new class extends mock() { + override readonly hasFocus = true; + override readonly onDidChangeFocus = Event.None; + override readonly onDidChangeActiveWindow = Event.None; + }(), + )); + const button = parent.querySelector('.chat-pet-button'); + assert.ok(button); + button.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); + const locked = menu?.getActions().find(action => action.id === CHAT_PET_CHANGE_COLOR_COMMAND_ID); + const lockedState = { label: locked?.label, enabled: locked?.enabled }; + assert.ok(locked); + await locked.run(); + service.unlockAchievement(ChatPetAchievementIds.Blobby); + button.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); + const actions = menu?.getActions(); + const unlocked = actions?.find(action => action.id === CHAT_PET_CHANGE_COLOR_COMMAND_ID); + assert.ok(unlocked); + await unlocked.run(); + assert.deepStrictEqual({ + lockedState, + unlockedState: { label: unlocked.label, enabled: unlocked.enabled }, + legacyVariants: actions?.filter(action => action.id.startsWith('chat.pet.variant.')).length, + revealsSlashCommand: actions?.some(action => action.label.includes('/blobby')), + commands, + }, { + lockedState: { label: 'Change Color', enabled: true }, + unlockedState: { label: 'Change Color', enabled: true }, + legacyVariants: 0, + revealsSlashCommand: false, + commands: [CHAT_PET_CHANGE_COLOR_COMMAND_ID, CHAT_PET_CHANGE_COLOR_COMMAND_ID], + }); + }); + test('resets pet size from the context menu', async () => { const parent = mainWindow.document.createElement('div'); const dragBounds = mainWindow.document.createElement('div'); @@ -1657,7 +1759,7 @@ suite('ChatPetWidget', () => { ]); }); - test('defines unique covered-antennae rewards for each achievement', () => { + test('defines unique hats and a color customization reward', () => { const accessoryIds = chatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.id)); assert.deepStrictEqual({ count: chatPetAchievements.length, @@ -1672,7 +1774,7 @@ suite('ChatPetWidget', () => { disabledAchievementIds: disabledChatPetAchievements.map(achievement => achievement.id), disabledAccessoryIds: disabledChatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.id)), }, { - count: 13, + count: 14, achievementIds: [ ChatPetAchievementIds.RequestRevision, ChatPetAchievementIds.FirstChatMessage, @@ -1687,6 +1789,7 @@ suite('ChatPetWidget', () => { ChatPetAchievementIds.ChatReferenceOpened, ChatPetAchievementIds.UsefulOutputCopied, ChatPetAchievementIds.AutopilotEnabled, + ChatPetAchievementIds.Blobby, ], accessoryIds: [ ChatPetAccessoryIds.TopHatMonocle, @@ -1720,7 +1823,7 @@ suite('ChatPetWidget', () => { 'wizard-hat', ], atlasCellSizes: Array(13).fill(96), - rewardCounts: Array(13).fill(1), + rewardCounts: [...Array(13).fill(1), 0], coversAntennae: true, crownAccessoryId: 'crown', disabledAchievementIds: [ @@ -1770,7 +1873,7 @@ suite('ChatPetWidget', () => { firstMessageRewards: getChatPetAchievement(ChatPetAchievementIds.FirstChatMessage).accessories.map(accessory => accessory.id), newAchievements: achievementIds.map(id => { const achievement = getChatPetAchievement(id); - return { title: achievement.title, reward: achievement.accessories[0].id }; + return { title: achievement.title, reward: achievement.accessories[0]?.id }; }), }, { firstMessageRewards: [ChatPetAccessoryIds.CowboyHat], @@ -1839,12 +1942,12 @@ suite('ChatPetWidget', () => { modelSwitch: { title: modelSwitch.title, description: modelSwitch.description, - accessoryId: modelSwitch.accessories[0].id, + accessoryId: modelSwitch.accessories[0]?.id, }, customSkill: { title: customSkill.title, description: customSkill.description, - accessoryId: customSkill.accessories[0].id, + accessoryId: customSkill.accessories[0]?.id, }, }, { modelSwitch: { diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index e4570a881bd9..af3e6e436c04 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -244,7 +244,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I }()); reg.defineInstance(IChatPetService, new class extends mock() { override readonly enabled = observableValue('chatPetEnabled', false); - override readonly variant = observableValue('chatPetVariant', 'stable' as const); + override readonly color = observableValue('chatPetColor', 'stable' as const); override readonly onTheRun = observableValue('chatPetOnTheRun', false); override readonly scale = observableValue('chatPetScale', 1); override readonly unlockedAchievements = observableValue('chatPetUnlockedAchievements', []); @@ -253,7 +253,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override readonly onDidUnlockAchievement = Event.None; override readonly horizontalPosition = observableValue('chatPetHorizontalPosition', undefined); override toggle() { return false; } - override setVariant() { } + override setColor() { } override setOnTheRun() { } override setScale(scale: number) { this.scale.set(scale, undefined); } override resetScale() { this.scale.set(1, undefined); } diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts index b7c13dc72aee..5dc00497107f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts @@ -7,6 +7,7 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { mainWindow } from '../../../../../base/browser/window.js'; import { FileAccess } from '../../../../../base/common/network.js'; import { allChatPetAccessories, chatPetAccessories, ChatPetAccessoryIds, getChatPetAccessory, type ChatPetAccessoryId, type IChatPetAccessory } from '../../../../contrib/chat/browser/chatPetAchievements.js'; +import { ChatPetColor, chatPetColorPresets, getChatPetColorVariant } from '../../../../contrib/chat/browser/chatPetColors.js'; import { drawChatPetComposite, drawChatPetEyeAccessory, getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from '../../../../contrib/chat/browser/widget/chatPetAccessoryRenderer.js'; import { getChatPetFrameDurations, getChatPetSpriteName, doesChatPetStateTrackCursor, CHAT_PET_SING_FIXED_ORIENTATION_DECORATIONS, drawChatPetAchievementStar, type ChatPetState } from '../../../../contrib/chat/browser/widget/chatPetWidget.js'; import { getChatPetReducedMotionRigFrame } from '../../../../contrib/chat/browser/widget/chatPetAccessoryRig.js'; @@ -160,6 +161,14 @@ const allChatPetStates: readonly ChatPetState[] = [ ]; export default defineThemedFixtureGroup({ path: 'chat/chatPetAccessoryRig/' }, { + ColorPresets: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderColorPresets, + }), + CustomColorCriticalPoses: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderCriticalPoses(ctx, '#cc44aa'), + }), CriticalPoses: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, render: renderCriticalPoses, @@ -186,7 +195,39 @@ export default defineThemedFixtureGroup({ path: 'chat/chatPetAccessoryRig/' }, { }), }); -async function renderCriticalPoses(ctx: ComponentFixtureContext): Promise { +async function renderColorPresets(ctx: ComponentFixtureContext): Promise { + configureChatPetFixtureFileRoot(ctx.disposableStore); + ctx.container.style.width = '760px'; + ctx.container.style.padding = 'var(--vscode-spacing-size240)'; + ctx.container.style.background = 'var(--vscode-editor-background)'; + ctx.container.style.color = 'var(--vscode-foreground)'; + const grid = DOM.append(ctx.container, DOM.$('div')); + grid.style.display = 'grid'; + grid.style.gridTemplateColumns = 'repeat(5, 1fr)'; + grid.style.gap = 'var(--vscode-spacing-size160)'; + const bodies = new Map(await Promise.all((['stable', 'insiders'] as const).map(async variant => [ + variant, + await loadImage(FileAccess.asBrowserUri(`vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-idle-${variant}-96.png`).toString(true)), + ] as const))); + for (const preset of chatPetColorPresets) { + const card = DOM.append(grid, DOM.$('div')); + DOM.append(card, DOM.$('div')).textContent = preset.label; + const canvas = DOM.append(card, DOM.$('canvas')); + canvas.width = 96; + canvas.height = 96; + canvas.style.imageRendering = 'pixelated'; + canvas.setAttribute('aria-hidden', 'true'); + const image = bodies.get(getChatPetColorVariant(preset.color)); + const context = canvas.getContext('2d'); + if (!image || !context) { + throw new Error('Unable to render the pet color fixture.'); + } + context.imageSmoothingEnabled = false; + drawChatPetComposite(context, image, undefined, 0, 0, 96, 96, 'right', 'idle', undefined, true, true, false, preset.color); + } +} + +async function renderCriticalPoses(ctx: ComponentFixtureContext, color?: ChatPetColor): Promise { configureChatPetFixtureFileRoot(ctx.disposableStore); ctx.container.style.width = '1100px'; @@ -279,6 +320,7 @@ async function renderCriticalPoses(ctx: ComponentFixtureContext): Promise true, accessory.eyeAccessoryMirrorsWithFacing !== false, accessory.coversAntennae === true, + color, ); } } diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts index 02a2ce34bebc..0e66284412c6 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts @@ -8,16 +8,19 @@ import { mainWindow } from '../../../../../base/browser/window.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ChatPetAchievementsEditor } from '../../../../contrib/chat/browser/chatPetAchievementsEditor.js'; -import { ChatPetAchievementsEditorInput } from '../../../../contrib/chat/browser/chatPetAchievementsEditorInput.js'; +import { ChatPetAchievementsEditorInput, ChatPetCustomizationTab } from '../../../../contrib/chat/browser/chatPetAchievementsEditorInput.js'; import { chatPetAchievements, ChatPetAccessoryIds, ChatPetAchievementIds } from '../../../../contrib/chat/browser/chatPetAchievements.js'; import { IChatPetService } from '../../../../contrib/chat/browser/chatPetService.js'; import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { TestChatEntitlementService } from '../../../common/workbenchTestServices.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; import { configureChatPetFixtureFileRoot, FixtureChatPetService, IChatPetFixtureOptions } from './chatPetFixtureUtils.js'; interface IAchievementsEditorFixtureOptions extends IChatPetFixtureOptions { readonly width?: number; readonly height?: number; + readonly tab?: ChatPetCustomizationTab; } function createMockEditorGroup(): IEditorGroup { @@ -39,16 +42,62 @@ async function renderAchievementsEditor(context: ComponentFixtureContext, option additionalServices: registry => { registerWorkbenchServices(registry); registry.defineInstance(IChatPetService, chatPetService); + registry.defineInstance(IChatEntitlementService, new TestChatEntitlementService()); }, }); const editor = context.disposableStore.add(instantiationService.createInstance(ChatPetAchievementsEditor, createMockEditorGroup())); editor.create(context.container); editor.layout(new Dimension(width, height)); const input = context.disposableStore.add(ChatPetAchievementsEditorInput.getOrCreate()); - await editor.setInput(input, undefined, {}, CancellationToken.None); + await editor.setInput(input, { tab: options.tab }, {}, CancellationToken.None); + if (options.tab === 'color') { + await new Promise((resolve, reject) => { + const deadline = mainWindow.performance.now() + 5_000; + const check = () => { + const previews = Array.from(context.container.querySelectorAll('.chat-pet-color-preview')); + const scrollbars = Array.from(context.container.querySelectorAll('.scrollbar')); + const scrollbarsSettled = scrollbars.every(scrollbar => scrollbar.classList.contains('invisible') && mainWindow.getComputedStyle(scrollbar).opacity === '0'); + if (scrollbarsSettled && previews.length === 11 && previews.every(preview => preview.getContext('2d')?.getImageData(0, 0, preview.width, preview.height).data.some((value, index) => index % 4 === 3 && value !== 0))) { + resolve(); + } else if (mainWindow.performance.now() >= deadline) { + reject(new Error('Color customization previews and scrollbars did not finish rendering.')); + } else { + mainWindow.requestAnimationFrame(check); + } + }; + check(); + }); + } } export default defineThemedFixtureGroup({ path: 'chat/petAchievements/standaloneModal/' }, { + ColorLocked: defineComponentFixture({ + labels: { kind: 'screenshot' }, + virtualTime: { enabled: false }, + render: context => renderAchievementsEditor(context, { enabled: true, tab: 'color' }), + }), + ColorUnlocked: defineComponentFixture({ + labels: { kind: 'screenshot' }, + virtualTime: { enabled: false }, + render: context => renderAchievementsEditor(context, { + enabled: true, + tab: 'color', + unlockedAchievements: [ChatPetAchievementIds.Blobby], + color: '#a277e6', + }), + }), + ColorNarrow: defineComponentFixture({ + labels: { kind: 'screenshot' }, + virtualTime: { enabled: false }, + render: context => renderAchievementsEditor(context, { + enabled: true, + tab: 'color', + unlockedAchievements: [ChatPetAchievementIds.Blobby], + color: '#abc123', + width: 360, + height: 640, + }), + }), AllLocked: defineComponentFixture({ labels: { kind: 'screenshot' }, render: context => renderAchievementsEditor(context, { enabled: true }), diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts index 7f6aa6f724c3..67f736b67c82 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts @@ -9,6 +9,7 @@ import { Disposable, DisposableStore, toDisposable } from '../../../../../base/c import { IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { ChatPetAccessoryId, ChatPetAchievementId } from '../../../../contrib/chat/browser/chatPetAchievements.js'; import { ChatPetVariant, IChatPetService } from '../../../../contrib/chat/browser/chatPetService.js'; +import { ChatPetColor } from '../../../../contrib/chat/browser/chatPetColors.js'; export interface IChatPetFixtureOptions { readonly enabled: boolean; @@ -16,6 +17,7 @@ export interface IChatPetFixtureOptions { readonly unseenAchievements?: readonly ChatPetAchievementId[]; readonly selectedAccessory?: ChatPetAccessoryId; readonly variant?: ChatPetVariant; + readonly color?: ChatPetColor; } export class FixtureChatPetService extends Disposable implements IChatPetService { @@ -24,8 +26,8 @@ export class FixtureChatPetService extends Disposable implements IChatPetService private readonly enabledValue: ISettableObservable; readonly enabled: IObservable; - private readonly variantValue: ISettableObservable; - readonly variant: IObservable; + private readonly colorValue: ISettableObservable; + readonly color: IObservable; private readonly onTheRunValue = observableValue(this, false); readonly onTheRun: IObservable = this.onTheRunValue; private readonly scaleValue = observableValue(this, 1); @@ -44,8 +46,8 @@ export class FixtureChatPetService extends Disposable implements IChatPetService super(); this.enabledValue = observableValue(this, options.enabled); this.enabled = this.enabledValue; - this.variantValue = observableValue(this, options.variant ?? 'stable'); - this.variant = this.variantValue; + this.colorValue = observableValue(this, options.color ?? options.variant ?? 'stable'); + this.color = this.colorValue; this.unlockedAchievementsValue = observableValue(this, options.unlockedAchievements ?? []); this.unlockedAchievements = this.unlockedAchievementsValue; this.unseenAchievementsValue = observableValue(this, options.unseenAchievements ?? []); @@ -60,8 +62,8 @@ export class FixtureChatPetService extends Disposable implements IChatPetService return enabled; } - setVariant(variant: ChatPetVariant): void { - this.variantValue.set(variant, undefined); + setColor(color: ChatPetColor): void { + this.colorValue.set(color, undefined); } setOnTheRun(onTheRun: boolean): void { diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 51ca17ddc9d6..b35241b11b16 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -31,7 +31,7 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/e67ff22f55b85ba448486d56e6e7e01e060fa8f7503c72638ef1b999015d829f) #### chat/petAchievements/standaloneModal/chatPetAchievementsEditor/MixedSelected/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b4e697f87416c5f384d7dd0338fcf76ca128bf695efa96ed885abc124a8736e1) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/25cedd0bedf186071991ad692608c4a88280cf720cfa89e1a93776659ab86bc1) #### comments/commentTree/CommentTreeMetadataZoomed/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a81446b88a24acb43f7713d0d84c921bb9d79d4c06448e77b4baae5c1f5c9026) @@ -91,7 +91,7 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/2945563f28440c62c812e2c80ac40620807822ec8b691bac9b79a85610b4fc50) #### sessions/accountMenu/petAchievementBadges/chatPetAchievementBadges/AllBadges/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a92e0c554f09d1e7d3b864d0b1cd5c70c057b2acbe4fb67daadeba0cb8351eb8) #### sessions/chat/newWidget/newChatWidget/MigrationsBackground/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/8e385d1dbb4f325dfd536c0965773398e726bd7868e01949afb2c740a9ef84af) From 66fe8a828b1e7e457910aa06d54481459a3c1fb2 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Sun, 4 Oct 2026 22:00:36 +0200 Subject: [PATCH 14/49] sessions: enable unified workspace picker by default (#339518) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/agentSessionsConfiguration.ts | 2 +- .../chat/test/browser/chatConfiguration.test.ts | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessionsConfiguration.ts b/src/vs/workbench/contrib/chat/browser/agentSessionsConfiguration.ts index 9bd31f22bc3e..5a08a56a1cd7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessionsConfiguration.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessionsConfiguration.ts @@ -64,7 +64,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis properties: { [ChatConfiguration.UnifiedWorkspacePicker]: { type: 'boolean', - default: product.quality !== 'stable', + default: true, scope: ConfigurationScope.APPLICATION, description: nls.localize('sessions.chat.unifiedWorkspacePicker.enabled', "Controls whether the Agents Window uses the unified workspace picker, which combines GitHub and remote workspaces, provides search, and, when supported, allows creating sessions with no workspace."), tags: ['experimental'], diff --git a/src/vs/workbench/contrib/chat/test/browser/chatConfiguration.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatConfiguration.test.ts index 7dd12eb3a195..15bfe05af7f3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatConfiguration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatConfiguration.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { ConfigurationMigration, Extensions as WorkbenchConfigurationExtensions, IConfigurationMigrationRegistry } from '../../../../common/configuration.js'; import { ChatConfiguration } from '../../common/constants.js'; @@ -21,6 +21,7 @@ const registeredAgentSessionsSettings = [ ChatConfiguration.AutoMarkAsDoneMergedSessionsAfterDays, ChatConfiguration.AutoDeleteMarkedAsDoneMergedSessionsAfterDays, ].map(key => configurationProperties[key] !== undefined); +const unifiedWorkspacePickerSetting = configurationProperties[ChatConfiguration.UnifiedWorkspacePicker]; const migrations = Registry.as(WorkbenchConfigurationExtensions.ConfigurationMigration).migrations; const legacyAutoArchiveMigration = migrations.find(migration => migration.key === 'chat.agentSessions.autoArchiveMergedSessionsAfterDays'); const legacyAutoDeleteArchivedMigration = migrations.find(migration => migration.key === 'chat.agentSessions.autoDeleteArchivedMergedSessionsAfterDays'); @@ -36,6 +37,20 @@ suite('Chat configuration', () => { assert.deepStrictEqual(registeredAgentSessionsSettings, [true, true, true]); }); + test('enables the unified workspace picker by default while allowing experiment overrides', () => { + assert.deepStrictEqual({ + type: unifiedWorkspacePickerSetting.type, + default: unifiedWorkspacePickerSetting.default, + scope: unifiedWorkspacePickerSetting.scope, + experiment: unifiedWorkspacePickerSetting.experiment, + }, { + type: 'boolean', + default: true, + scope: ConfigurationScope.APPLICATION, + experiment: { mode: 'auto' }, + }); + }); + test('Marketplace visibility is experiment-controlled and default-off while the GitHub Feed is default-on', () => { assert.deepStrictEqual({ marketplace: customizationMarketplaceConfigurationProperties[CustomizationMarketplaceConfiguration.MarketplaceEnabled], From 1b412f2dd82973b46dcdced377e085c0447a3b7e Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Sun, 4 Oct 2026 22:21:33 +0200 Subject: [PATCH 15/49] migrate with agent (#339334) * migrate with agent * update * update * update * fix tests * update --- .../skills/migrate-customizations/SKILL.md | 147 ++++++++++++++ .../references/migration-techniques.md | 82 ++++++++ extensions/copilot/package.json | 8 + .../test/node/builtinSkills.spec.ts | 43 ++++ .../aiCustomizationManagementEditor.ts | 93 ++++++++- .../aiCustomization/customizationMigration.ts | 39 +++- .../customizationMigrationAccessibility.ts | 1 + .../customizationMigrationDashboard.ts | 32 ++- .../media/customizationMigrationDashboard.css | 11 +- .../chatSessions/chatSessions.contribution.ts | 15 +- .../customizationMigrationTelemetryService.ts | 190 +++++++++++++++++- .../electron-browser/chat.contribution.ts | 2 +- .../aiCustomizationManagementEditor.test.ts | 1 + .../customizationMigration.test.ts | 57 +++++- .../customizationMigrationDashboard.test.ts | 26 ++- ...omizationMigrationTelemetryService.test.ts | 39 +++- ...customizationMigrationDashboard.fixture.ts | 1 + 17 files changed, 755 insertions(+), 32 deletions(-) create mode 100644 extensions/copilot/assets/prompts/skills/migrate-customizations/SKILL.md create mode 100644 extensions/copilot/assets/prompts/skills/migrate-customizations/references/migration-techniques.md diff --git a/extensions/copilot/assets/prompts/skills/migrate-customizations/SKILL.md b/extensions/copilot/assets/prompts/skills/migrate-customizations/SKILL.md new file mode 100644 index 000000000000..a75be3fa7560 --- /dev/null +++ b/extensions/copilot/assets/prompts/skills/migrate-customizations/SKILL.md @@ -0,0 +1,147 @@ +--- +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. +argument-hint: Launch from Agent Customizations > Migrations > Migrate with Agent +user-invocable: true +disable-model-invocation: true +--- + +# Migrate Agent Customizations + +Guide the user through migrating the inventory included in the invoking prompt. The source locations and valid target folders in that prompt were evaluated for the selected harness. Treat them as authoritative: do not substitute hardcoded destination paths or migrate to a folder that is not listed. + +## Required Migration Context + +Before doing anything else, verify that the invoking prompt includes all of the following VS Code-generated values: + +- Selected harness +- Migration telemetry flow +- Recovery bundle folder and filesystem path +- Customizations that need migration +- Valid target folders reported by the selected harness + +If any value is missing, do not inspect, copy, move, convert, or delete customizations. Explain that this workflow must be started from **Agent Customizations > Migrations** by selecting **Migrate with Agent**, then stop. Do not ask the user to manually reconstruct or paste the missing context. + +Read [migration techniques](./references/migration-techniques.md) before proposing changes. + +## Safety Contract + +1. Never modify a customization before creating a recovery bundle. +2. Use the recovery bundle folder supplied in the invoking prompt. VS Code created and is watching this folder; do not move it or substitute another location. +3. In the recovery bundle, create: + - `migration-log.md`, containing the selected harness, scope, timestamps, source and destination paths, commands or tools used, validation results, and every user decision. + - `backups/`, containing a byte-for-byte copy of every file before its first modification or deletion. + - `restore.md`, containing exact steps for restoring the backups and reverting newly created files. + - `migration-results.json`, containing only the privacy-safe aggregate schema described in Finish. VS Code watches this file for the final migration outcome. +4. Append to the log after every attempted operation, including failures and rollbacks. +5. Approval to migrate a group authorizes destination writes only; it does not authorize deleting source files, removing source MCP entries, or clearing location settings. Preserve sources by default. After the destination is validated, ask separately for explicit approval for each kind of cleanup. If runtime validation is not possible, explain what was and was not verified, keep the source, and ask whether to defer cleanup. +6. Stop on an unexpected source change, destination conflict, invalid configuration, failed backup, or failed validation. Explain the problem instead of guessing. +7. Never include customization names, paths, contents, MCP configuration, or other user data in telemetry. +8. Classify each inventory item exactly once using its supplied category and scope. Do not offer the same item again in another group; if groups overlap, explain the overlap and use the item's inventory category to decide where it is handled. + +## Workflow + +### 1. Choose Scope + +Ask whether to migrate: + +- user customizations, +- workspace customizations, or +- both. + +Do not act on an unselected scope. + +If workspace customizations are selected, ask whether the user wants a pull request. If they do, keep workspace changes focused, include the validation evidence and migration rationale in the pull request, and do not include a recovery bundle stored outside the repository. + +### 2. Review the Plan + +Classify the supplied inventory by its reported category and scope, then handle applicable items in this order: + +1. prompt files that should become skills; +2. VS Code profile user-data agents, instructions, and skills; +3. MCP servers; +4. workspace or user customizations at locations defined by settings `chat.agentSkillsLocations`, `chat.instructionsFilesLocations`, and `chat.agentFilesLocations`. + +The groups are mutually exclusive for an inventory item. In particular, a file reported under `configuredLocations` belongs in group 4, not group 2, even if its type is agent, instruction, or skill. If a previously skipped item would otherwise reappear in a later group, do not ask about it again unless the user requests reconsideration. + +Before each group: + +- explain why the migration is useful; +- list the source and harness-reported destination locations; +- describe changes that cannot be preserved; +- state explicitly that approval covers destination writes only and that source cleanup will require a separate approval; +- ask for confirmation to proceed with that group. + +Work one group at a time. Do not request approval for all writes at once. + +### 3. Migrate Prompt Files to Skills + +Explain the compatibility reason for converting each prompt to a skill. Convert each selected prompt into a skill directory with a `SKILL.md`, preserving supported name, description, argument guidance, invocation semantics, and body content. Add `disable-model-invocation: true` when needed to preserve an explicit-invocation-only prompt. Record unsupported frontmatter in the log for review; do not silently drop behavior. + +Validate that each skill has valid frontmatter, a meaningful description, and a folder name that matches the skill name. Distinguish structural validation from runtime validation: test discovery and representative invocation in the selected destination harness when possible. A file inspection or a test against a different harness is not proof that the destination harness loads the skill. If runtime validation requires a new session or is otherwise unavailable, say so, leave the source in place, and defer cleanup unless the user explicitly chooses otherwise after hearing the limitation. + +### 4. Migrate User Data + +Explain that Agent Host doesn't read the VS Code profile user data folder. It only reads the harness's user folders. Copy selected agents, instructions, and skills to a compatible listed destination without silently changing their contents. +Keep user-scope items in user scope. Explain that approval to copy does not authorize deleting the VS Code source or changing sync/location settings; ask for those separately after validation. + +### 5. Migrate MCP Servers + +Explain that moving MCP configuration lets the selected Agent Host load the server directly. Review every server separately, including destination conflicts and properties the destination format cannot preserve. Call out that a disabled user server might become enabled after migration. + +Write and statically validate the destination before considering source cleanup. Leave unselected, unsupported, and conflicting servers unchanged. Test that migrated servers are discovered and can start in the selected destination harness; spawning a command directly can verify process behavior, but does not by itself prove that the harness discovers or loads the configuration. If destination-harness testing is unavailable, report that limitation and keep source entries. + +Approval for this group authorizes destination writes only. After successful destination validation, ask separately whether to remove each migrated or already-equivalent source entry. Do not remove a source entry merely because its destination entry was written or because the user approved the group. + +### 6. Customizations at Custom Locations + +Files in custom locations defined by `chat.agentSkillsLocations`, `chat.instructionsFilesLocations`, and `chat.agentFilesLocations` can continue to work when the selected harness is connected to VS Code. Moving them to a harness-reported standard location may improve portability or consistency, but is not automatically required for compatibility. Explain this trade-off before asking whether to migrate. + +Handle only items reported as `configuredLocations` and not already handled in another group. Do not re-offer files the user explicitly skipped. Treat moving a file, deleting its old copy, and clearing or changing the location setting as separate actions; obtain explicit approval for each cleanup action after validating the destination. + +### 7. Finish + +Before the final summary, write `migration-results.json` in the recovery bundle: + +```json +{ + "version": 1, + "migrationFlowId": "", + "cancelled": false, + "results": [ + { + "category": "promptFiles", + "scope": "workspace", + "customizationType": "skill", + "outcome": "migrated", + "count": 1 + } + ] +} +``` + +Aggregate results by: + +- category: `promptFiles`, `userData`, `configuredLocations`, or `mcpServers`; +- scope: `user` or `workspace`; +- customization type: `agent`, `instructions`, `skill`, or `mcpServer`; +- outcome: `migrated`, `skipped`, or `failed`. + +The total reported for each category, scope, and customization type must not exceed the inventory supplied by VS Code. The result file must never include names, paths, contents, configuration values, error messages, or other user data. + +Write the validated JSON to the exact recovery bundle supplied by VS Code. Do not write a result file when no migration telemetry flow was supplied. + +If the user stops the workflow, set `cancelled` to `true`, record completed work with its actual outcome, mark the remaining inventory as `skipped`, and still validate and write the result file before the final cancellation summary. + +Summarize: + +- migrated, skipped, and failed items; +- created, modified, deleted, and retained files; +- validation performed; +- cleanup still pending; +- recovery bundle and restoration instructions; +- pull request URL when one was requested. + +Keep the recovery bundle until the user confirms the migrated customizations work. + +For a requested pull request, verify the final diff contains only approved workspace changes, exclude the recovery bundle, and include the migration rationale and actual validation performed. Do not claim runtime validation when only static checks or direct process tests were possible. diff --git a/extensions/copilot/assets/prompts/skills/migrate-customizations/references/migration-techniques.md b/extensions/copilot/assets/prompts/skills/migrate-customizations/references/migration-techniques.md new file mode 100644 index 000000000000..be4ded8047d3 --- /dev/null +++ b/extensions/copilot/assets/prompts/skills/migrate-customizations/references/migration-techniques.md @@ -0,0 +1,82 @@ +# Agent Customization Migration Techniques + +Use these techniques together with the source inventory and target folders supplied by VS Code. Harness-reported destinations take precedence over examples in general documentation. + +## General Technique + +- Back up every source before writing. +- Group approval authorizes destination writes only. Preserve each source by default; ask for explicit cleanup approval separately, after destination validation. +- Keep user and workspace scope unchanged unless the user explicitly chooses otherwise. +- Classify each inventory item once by its supplied category and scope. Do not offer a skipped item again in another group unless the user asks to reconsider it. +- In a multi-root workspace, keep each migrated customization in the corresponding workspace root. +- Verify discovery and representative behavior in the destination environment. Remote and Dev Container user folders belong to that environment. +- Migrated and original copies do not synchronize. Remove the original only after validation. +- Separate structural checks (file shape, frontmatter, or valid JSON) from runtime checks in the selected harness. A direct server-process test is useful evidence about that process, but does not prove the harness discovers or loads its configuration. If destination-harness testing is unavailable, report the limitation and keep the source unless the user explicitly chooses otherwise after being informed. + +## Prompt Files to Skills + +Agent Host harnesses do not load `*.prompt.md` files. Convert a prompt to `/SKILL.md`. + +- Preserve supported `name`, `description`, and argument guidance. +- Preserve invocation semantics. Add `disable-model-invocation: true` when needed to retain an explicit-invocation-only prompt. +- Choose a valid, unique skill folder name. +- Record unsupported prompt frontmatter rather than silently discarding it. +- Keep the prompt body focused as a reusable workflow. +- Validate the skill's frontmatter and invoke it with a representative request. +- Do not treat structural validation as proof of runtime discovery. Test the skill in the selected destination harness when possible; if testing requires a new session, defer it and keep the prompt until the user has been told runtime validation is pending. + +## User Data Locations + +VS Code profile user data are Local-only. Move agents, instructions, and skills to a destination reported by the selected harness. + +- Preserve file type, name, and content unless a compatibility issue requires an explained change. +- Keep workspace files in their originating repository root. +- Explain that harness user folders might not participate in VS Code Settings Sync. +- Treat copying a file, deleting the source, and clearing or changing a location setting as separate decisions. Group migration approval authorizes the copy only. +- `chat.promptFilesLocations` entries are prompt-to-skill migrations, not ordinary location moves. + +## MCP Servers + +Migrate each MCP server independently. + +- Review source and destination JSON before writing. +- Detect duplicate server names and non-equivalent destination entries. +- Explain how every source property and variable will migrate by using the table below. +- A disabled user server might become enabled at the destination; review enablement before starting a session. +- Write and statically validate the destination entry before runtime testing or considering source cleanup. +- Leave unsupported, invalid, conflicting, and unselected servers unchanged. +- Do not remove a source entry solely because an equivalent destination entry already exists. After successful validation, ask separately before removing each migrated or redundant source entry. + +| Source configuration | Migration result | What to do | +|----------------------|------------------|------------| +| Standard `command`, `args`, `env`, `url`, and `headers` values | Migrate automatically and add `tools: ["*"]` | Inspect the server's tools. Replace `*` with the specific tools the agent should invoke when access to every server tool is not appropriate. | +| `${workspaceFolder}`, `${workspaceRoot}`, `${workspaceFolderBasename}`, `${workspaceRootFolderName}`, `${cwd}`, or `${pathSeparator}` | Resolve the variable and write its current value | Review the resulting value before sharing the destination file or using it on another machine. | +| `gallery`, `version`, `dev`, or `sandboxEnabled` | Migrate while removing these properties | Explain the removal and decide how to handle updates, development behavior, or sandboxing after migration. | +| `${input:...}`, `${config:...}`, `${command:...}`, or other interactive VS Code variables | Do not migrate automatically | Reconfigure the value for the destination harness. Never copy a resolved secret into the MCP file. | +| `${env:NAME}` | Do not migrate automatically | Use `$NAME`, `${NAME}`, or `${NAME:-default}`, and define the variable in the Agent Host environment. | +| `cwd` | Do not migrate automatically | Add `cwd` manually only when the server requires it, and verify the path on the Agent Host machine. | +| `envFile` | Do not migrate automatically | Export the required variables in the Agent Host environment and reference them from `env`. Never copy secret values into the MCP file. | +| SSE transport | Do not migrate automatically | Use `type: "sse"` only when the server does not support Streamable HTTP. SSE is deprecated. | +| A VS Code `oauth` object | Do not migrate automatically | Remove the nested object to use OAuth discovery, or translate supported client settings to the flat destination OAuth fields. Authenticate when prompted. | +| Environment variables with `null` values | Do not migrate automatically | Remove the entry or provide a supported value. | +| Additional VS Code-specific properties | Do not migrate automatically | Remove or replace the unsupported property before retrying. | +| A different server with the same name in the destination or another workspace root | Stop without changing the source | Rename or remove the conflicting server, and then retry. | + +Input variables require manual configuration. VS Code can prompt for values such as API keys through `${input:api-key}`, but the destination MCP format does not use that input flow. Ask the user how the value should be supplied securely in the Agent Host environment. + +## Custom Locations + +Agents, skill, and instruction files located in custom locations defined by settings `chat.agentSkillsLocations`, `chat.instructionsFilesLocations`, and `chat.agentFilesLocations` can continue to work when the selected harness is connected to VS Code. Moving them to a harness-reported standard location may improve portability or consistency, but is not automatically required for compatibility. Explain the trade-off rather than presenting the move as mandatory. + +Handle only items reported under `configuredLocations` and not already handled under another inventory category. Do not re-ask about files the user explicitly skipped. Treat copying/moving a file, deleting its old copy, and clearing or changing the location setting as separate actions requiring separate approval. + +## Verification + +After each migration group: + +1. Confirm the item appears in the Agent Customizations editor for the selected harness. +2. Start a session with that harness and run a representative task. +3. Confirm MCP servers start and expose the expected tools. +4. Review version-control changes for workspace migrations. +5. Report structural validation separately from runtime validation. If the selected harness cannot be exercised in this session, mark runtime validation as pending and keep source files and entries. +6. Only after successful destination validation, ask for explicit approval before removing old files, MCP entries, or settings. Approval to migrate is not approval to clean up. diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 285b63128c4a..aa834bda34a0 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6866,6 +6866,14 @@ "copilotcli" ] }, + { + "path": "./assets/prompts/skills/migrate-customizations/SKILL.md", + "sessionTypes": [ + "copilotcli", + "claude", + "codex" + ] + }, { "path": "./assets/prompts/skills/init/SKILL.md", "sessionTypes": [ diff --git a/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts b/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts index 3cc7a60db09e..6e519420008b 100644 --- a/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts +++ b/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts @@ -60,4 +60,47 @@ suite('built-in skills', () => { expect(skill).not.toContain('extensions_manage({'); expect(skill).not.toContain('run `/extensions`'); }); + + test('contributes the customization migration skill to Agent Host sessions', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(copilotRoot, 'package.json'), 'utf-8')) as ICopilotPackage; + const contributions = manifest.contributes?.chatSkills?.filter(skill => skill.path.endsWith('/migrate-customizations/SKILL.md')); + const skill = fs.readFileSync( + path.join(copilotRoot, 'assets', 'prompts', 'skills', 'migrate-customizations', 'SKILL.md'), + 'utf-8', + ); + expect(skill).not.toContain('report_customization_migration'); + + expect({ + contributions, + requiredContent: [ + 'name: migrate-customizations', + 'user-invocable: true', + 'disable-model-invocation: true', + 'Agent Customizations > Migrations', + 'Do not ask the user to manually reconstruct or paste the missing context', + './references/migration-techniques.md', + 'migration-log.md', + 'restore.md', + 'pull request', + 'migration-results.json', + ].filter(anchor => skill.includes(anchor)), + }).toEqual({ + contributions: [{ + path: './assets/prompts/skills/migrate-customizations/SKILL.md', + sessionTypes: ['copilotcli', 'claude', 'codex'], + }], + requiredContent: [ + 'name: migrate-customizations', + 'user-invocable: true', + 'disable-model-invocation: true', + 'Agent Customizations > Migrations', + 'Do not ask the user to manually reconstruct or paste the missing context', + './references/migration-techniques.md', + 'migration-log.md', + 'restore.md', + 'pull request', + 'migration-results.json', + ], + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index f29000d9bb3b..334fb4d935fd 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -35,7 +35,7 @@ import { IDialogService, IFileDialogService } from '../../../../../platform/dial import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { EditorPane } from '../../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../../common/editor.js'; -import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; +import { IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; @@ -72,6 +72,7 @@ import { } from './aiCustomizationManagement.js'; import { skillIcon } from './aiCustomizationIcons.js'; import { ChatModelsWidget } from '../chatManagement/chatModelsWidget.js'; +import { IChatWidgetService } from '../chat.js'; import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { CustomizationMigration, CustomizationMigrationCandidate, CustomizationMigrationType, FileCustomizationMigrationFailureReason, getCustomizationMigrationTargetType, getMcpServerCustomizationMigrationCandidateKey, ICustomizationMigrationService, IMcpServerCustomizationMigrationCandidate, IMcpServerCustomizationMigrationExclusion, IMcpServerCustomizationMigrationResult, isMcpServerCustomizationMigrationCandidate, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; import { ICustomizationMigrationTelemetryService } from '../../common/promptSyntax/service/customizationMigrationTelemetryService.js'; @@ -85,6 +86,7 @@ import { showConfigureHooksQuickPick } from '../promptSyntax/hookActions.js'; import { resolveWorkspaceTargetDirectory, resolveUserTargetDirectory, CustomizationLocationPicker } from './customizationCreatorService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { isAgentHostTarget, isRemoteAgentHostTarget } from '../../common/chatSessionsService.js'; import { hasReadableCustomizationContent } from '../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; @@ -98,6 +100,7 @@ import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditor import { IWorkingCopyService } from '../../../../services/workingCopy/common/workingCopyService.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { FileSystemProviderCapabilities, IFileService } from '../../../../../platform/files/common/files.js'; +import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; @@ -118,7 +121,7 @@ import { ICustomizationHarnessService, type ICustomizationSourceFolder } from '. import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage, type ICustomizationMarketplaceOrigin, type ICustomizationMigrationCategorySummary, type IInstalledCustomizationTarget } from './aiCustomizationWelcomePage.js'; import { ICustomizationMarketplaceInstallService } from '../../common/customizationMarketplaceInstallService.js'; -import { type CustomizationMigrationTargetFolders, type IMigratedCustomizationsWithFailureReasonsResult, migrateCustomizations, resolveWorkspaceMigrationTargetFolder } from './customizationMigration.js'; +import { createCustomizationMigrationAgentPrompt, type CustomizationMigrationTargetFolders, type IMigratedCustomizationsWithFailureReasonsResult, migrateCustomizations, resolveWorkspaceMigrationTargetFolder } from './customizationMigration.js'; import { CUSTOMIZATION_MIGRATION_CATEGORIES, CustomizationMigrationCategoryId, getCustomizationMigrationCategory, homepageMigrationCategories, type ICustomizationMigrationCategory } from './customizationMigrationCategories.js'; import { CustomizationMigrationDashboard, @@ -132,7 +135,6 @@ import { IViewsService } from '../../../../services/views/common/viewsService.js import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { showNoFoldersDialog } from '../promptSyntax/pickers/askForPromptSourceFolder.js'; -import { isAgentHostTarget } from '../../common/chatSessionsService.js'; const $ = DOM.$; const CUSTOMIZATION_MIGRATION_IGNORED_GROUPS_STORAGE_KEY = 'chat.customizationMigration.ignoredGroups'; @@ -530,6 +532,9 @@ export class AICustomizationManagementEditor extends EditorPane { @ICustomizationMarketplaceService private readonly marketplaceService: ICustomizationMarketplaceService, @ICustomizationMarketplaceInstallService private readonly marketplaceInstallService: ICustomizationMarketplaceInstallService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, ) { super(AICustomizationManagementEditor.ID, group, telemetryService, themeService, storageService); @@ -1060,6 +1065,9 @@ export class AICustomizationManagementEditor extends EditorPane { this.migrationResult = undefined; this.renderCustomizationMigrationDashboardState(); }, + migrateWithAgent: () => { + void this.startCustomizationMigrationWithAgent(); + }, migrateCategory: (id, storage) => { const category = getCustomizationMigrationCategory(id); const candidates = this.getMigrationCandidates(category, storage) @@ -1633,6 +1641,84 @@ export class AICustomizationManagementEditor extends EditorPane { return [...this.customizationsByMigrationCategory.values()].flat(); } + private async startCustomizationMigrationWithAgent(): Promise { + const harness = this.harnessService.getActiveDescriptor(); + if (isRemoteAgentHostTarget(harness.id)) { + return; + } + const sessionResource = this.harnessService.activeSessionResource.get(); + const customizations = [...this.customizationsByMigrationCategory].flatMap(([categoryId, candidates]) => { + const category = getCustomizationMigrationCategory(categoryId).migrationType; + return candidates + .filter(candidate => !this.isMigrationCategoryIgnored(categoryId, candidate.storage)) + .map(customization => ({ category, customization })); + }); + const inventoryCounts = new Map(); + for (const [categoryId, candidates] of this.customizationsByMigrationCategory) { + const count = candidates.filter(candidate => !this.isMigrationCategoryIgnored(categoryId, candidate.storage)).length; + if (count > 0) { + const category = getCustomizationMigrationCategory(categoryId); + inventoryCounts.set(category.migrationType, (inventoryCounts.get(category.migrationType) ?? 0) + count); + } + } + const migrationFlowId = generateUuid(); + if (!customizations.length) { + return; + } + + try { + const targetTypes = new Set(customizations + .map(({ customization }) => customization) + .filter(customization => !isMcpServerCustomizationMigrationCandidate(customization)) + .map(getCustomizationMigrationTargetType)); + const targetFolderEntries = await Promise.all([...targetTypes].map(async type => [ + type, + await harness.itemProvider?.provideSourceFolders?.(sessionResource, type, CancellationToken.None) ?? [], + ] as const)); + if (!isEqual(sessionResource, this.harnessService.activeSessionResource.get()) || harness.id !== this.harnessService.activeHarness.get()) { + return; + } + + const recoveryBundleFolder = URI.joinPath(this.environmentService.workspaceStorageHome, 'customizationMigrations', migrationFlowId); + await this.fileService.createFolder(recoveryBundleFolder); + + const prompt = createCustomizationMigrationAgentPrompt(harness, migrationFlowId, recoveryBundleFolder, customizations, new Map(targetFolderEntries)); + if (!await this.closeForAgentMigration()) { + return; + } + const migrationSessionResource = await this.commandService.executeCommand(`workbench.action.chat.openNewSessionSidebar.${harness.id}`, { prompt }); + if (migrationSessionResource) { + this.customizationMigrationTelemetryService.watchAgentMigrationResult( + URI.joinPath(recoveryBundleFolder, 'migration-results.json'), + migrationFlowId, + inventoryCounts, + migrationSessionResource, + { + onDidBackground: Event.map( + Event.filter(this.chatWidgetService.onDidBackgroundSession, resource => isEqual(resource, migrationSessionResource)), + () => undefined, + ), + onDidForeground: Event.filter( + this.chatWidgetService.onDidChangeFocusedSession, + () => isEqual(this.chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource, migrationSessionResource), + ), + }, + ); + } + } catch (error) { + onUnexpectedError(error); + this.notificationService.error(localize('startAgentMigrationFailed', "Could not start the agent-guided customization migration.")); + } + } + + private async closeForAgentMigration(): Promise { + const modalEditorPart = this.editorGroupsService.activeModalEditorPart; + if (modalEditorPart?.groups.some(group => group.id === this.group.id)) { + return modalEditorPart.close(); + } + return this.input ? this.group.closeEditor(this.input) : true; + } + private getMigrationCategorySummaries(): readonly ICustomizationMigrationCategorySummary[] { const harnessLabel = this.getActiveHarnessLabel(); const summaries: ICustomizationMigrationCategorySummary[] = []; @@ -2277,6 +2363,7 @@ export class AICustomizationManagementEditor extends EditorPane { }); return { scopes, + supportsAgentMigration: !isRemoteAgentHostTarget(this.harnessService.activeHarness.get()), manualReviewItems: storages.flatMap(storage => this.getMcpMigrationExclusions(storage).map(exclusion => ({ id: `${CustomizationMigrationCategoryId.McpServers}:${storage}:exclusion:${exclusion.id}`, label: exclusion.name, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts index 3cad9617d9fe..d575a6fb86fd 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts @@ -12,7 +12,7 @@ import { generateUuid } from '../../../../../base/common/uuid.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { getCleanPromptName, getPromptFileExtension, SKILL_FILENAME, VALID_SKILL_NAME_REGEX } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IHeaderAttribute, ParsedPromptFile, PromptFileParser, PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; -import { FileCustomizationMigrationFailureReason, getCustomizationMigrationTargetType, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; +import { CustomizationMigrationCandidate, CustomizationMigrationType, FileCustomizationMigrationFailureReason, getCustomizationMigrationTargetType, isMcpServerCustomizationMigrationCandidate, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; @@ -57,6 +57,43 @@ export interface ICustomizationMigrationOptions { readonly resolveTargetFolder?: (customization: MigratableConfiguration, targetType: PromptsType) => ICustomizationSourceFolder | undefined; } +export interface ICategorizedCustomizationMigrationCandidate { + readonly category: CustomizationMigrationType; + readonly customization: CustomizationMigrationCandidate; +} + +export function createCustomizationMigrationAgentPrompt( + harness: { readonly id: string; readonly label: string }, + migrationFlowId: string, + recoveryBundleFolder: URI, + customizations: readonly ICategorizedCustomizationMigrationCandidate[], + targetFoldersByType: ReadonlyMap, +): string { + const customizationLocations = customizations.map(({ category, customization }) => { + if (isMcpServerCustomizationMigrationCandidate(customization)) { + return `- ${category}: MCP server "${customization.name}" (${customization.storage}): ${customization.sourceUri.toString(true)} -> ${customization.targetUri.toString(true)}`; + } + return `- ${category}: ${customization.type} (${customization.storage}): ${customization.uri.toString(true)}`; + }); + const targetLocations = [...targetFoldersByType] + .flatMap(([type, folders]) => folders.map(folder => `- ${type} (${folder.source}, ${folder.label}): ${folder.uri.toString(true)}`)); + + return [ + '/migrate-customizations', + '', + `Selected harness: ${harness.label} (${harness.id})`, + `Migration telemetry flow: ${migrationFlowId}`, + `Recovery bundle folder: ${recoveryBundleFolder.toString(true)}`, + `Recovery bundle filesystem path: ${recoveryBundleFolder.fsPath}`, + '', + 'Customizations that need migration:', + ...customizationLocations, + '', + 'Valid target folders reported by the selected harness:', + ...(targetLocations.length ? targetLocations : ['- None reported for these customization types.']), + ].join('\n'); +} + /** * Picks the corresponding target folder in the customization's workspace group. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts index 2a7de3eac3f9..f264956d7ed3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts @@ -36,6 +36,7 @@ class CustomizationMigrationAccessibleView implements IAccessibleViewImplementat () => this.type === AccessibleViewType.Help ? [ localize('migrationHelpOverview', "The migrations tree groups each migration type by workspace or user. Expand a group to inspect each customization, its source location, and its destination."), localize('migrationHelpNavigation', "Use the arrow keys to move through and expand the tree. Use each row's checkbox to include or exclude it. Open a customization to view its editor or details. The More Actions menu can migrate or delete one item."), + localize('migrationHelpAgent', "Use Migrate to have VS Code update the selected customizations. Migrate with Agent closes Agent Customizations and focuses a guided migration in chat for the selected harness. Agent migration uses credits."), localize('migrationHelpActions', "Migrate applies to the selected items in one group and shows the planned source and destination changes before modifying files. Ignore permanently hides a group. Show Ignored Migrations restores hidden groups."), localize('migrationHelpLocations', "Activate a destination path to change the destination. Workspace MCP servers migrate to the root .mcp.json. User MCP servers migrate to mcp-config.json in Copilot home. Disabled user servers may become enabled after migration."), localize('migrationHelpMcpChanges', "MCP servers that migrate with changes explain each property removal in their row. Review these warnings before confirming migration."), diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts index b730417e26dd..0c59b26f2a77 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts @@ -96,15 +96,17 @@ export interface ICustomizationMigrationDashboardOverview { readonly scopes: readonly ICustomizationMigrationDashboardScope[]; readonly manualReviewItems?: readonly (ICustomizationMigrationDashboardItem & { readonly storage: PromptsStorage })[]; readonly hasIgnoredGroups?: boolean; + readonly supportsAgentMigration?: boolean; /** Most recent activity first. */ readonly activity: readonly ICustomizationMigrationDashboardActivity[]; readonly result?: { readonly migratedCount: number }; } export interface ICustomizationMigrationDashboardCallbacks { - readonly actionClicked: (action: 'retryClicked' | 'destinationsClicked' | 'migrationCategoryClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', categoryId?: CustomizationMigrationCategoryId) => void; + readonly actionClicked: (action: 'retryClicked' | 'destinationsClicked' | 'migrationCategoryClicked' | 'agentMigrationClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', categoryId?: CustomizationMigrationCategoryId) => void; readonly configureLocations: (id: CustomizationMigrationCategoryId, storage: PromptsStorage) => void; readonly dismissResult: () => void; + readonly migrateWithAgent: () => void; readonly migrateCategory: (id: CustomizationMigrationCategoryId, storage: PromptsStorage) => void; readonly setItemSelected: (item: ICustomizationMigrationDashboardItem, selected: boolean) => void; readonly showItemActions: (item: ICustomizationMigrationDashboardItem, storage: PromptsStorage, anchor: HTMLElement) => void; @@ -305,12 +307,19 @@ export class CustomizationMigrationDashboard extends Disposable { const migrationGroups = this.getMigrationGroups(overview); this.allMigrationsComplete = migrationGroups.length === 0 && !overview.hasIgnoredGroups; this.prepareRender(); + const supportsAgentMigration = migrationGroups.length > 0 && overview.supportsAgentMigration !== false; const page = this.renderHeader( localize('migrationsTitle', "Migrations"), this.allMigrationsComplete ? localize('migrationsCompletedDescription', "Your customizations use supported formats and locations.") - : localize('migrationsDescription', "Some of your agent customizations need an update to keep working. Review and migrate them to the new formats and locations."), + : supportsAgentMigration + ? localize('migrationsDescriptionWithAgent', "Some of your agent customizations need an update to keep working. Use Migrate to have VS Code update selected customizations, or Migrate with Agent for a guided migration in chat. Agent migration uses credits.") + : localize('migrationsDescription', "Some of your agent customizations need an update to keep working. Review and migrate them to the new formats and locations."), overview.hasIgnoredGroups === true, + supportsAgentMigration ? () => { + this.callbacks.actionClicked('agentMigrationClicked'); + this.callbacks.migrateWithAgent(); + } : undefined, ); if (overview.result) { @@ -408,15 +417,16 @@ export class CustomizationMigrationDashboard extends Disposable { return [...this.focusTargets].find(([, element]) => element === active)?.[0]; } - private renderHeader(title: string, description: string, hasIgnoredGroups = false): HTMLElement { + private renderHeader(title: string, description: string, hasIgnoredGroups = false, migrateWithAgent?: () => void): HTMLElement { const page = DOM.append(this.element, $('.migration-page')); const header = DOM.append(page, $('.migration-page-header')); const titleRow = DOM.append(header, $('.migration-page-title-row')); const heading = DOM.append(titleRow, $('h1', { tabindex: -1 }, title)); this.focusTargets.set('title', heading); + const actions = DOM.append(titleRow, $('.migration-page-title-actions')); if (hasIgnoredGroups) { this.button( - titleRow, + actions, 'restoreIgnored', localize('showIgnoredMigrations', "Show Ignored Migrations"), localize('showIgnoredMigrationsAriaLabel', "Show and restore ignored migrations"), @@ -424,6 +434,16 @@ export class CustomizationMigrationDashboard extends Disposable { 'link', ); } + if (migrateWithAgent) { + this.button( + actions, + 'migrateWithAgent', + localize('migrateWithAgent', "Migrate with Agent"), + localize('migrateWithAgentAriaLabel', "Start an agent-guided customization migration"), + migrateWithAgent, + 'secondary', + ); + } DOM.append(header, $('p.migration-intro', {}, description)); return page; } @@ -779,10 +799,10 @@ export class CustomizationMigrationDashboard extends Disposable { } } - private button(parent: HTMLElement, key: string, label: string, ariaLabel: string, run: () => void, kind: 'secondary' | 'link' | 'icon' = 'secondary', icon?: ThemeIcon, disposables: DisposableStore = this.renderDisposables): Button { + private button(parent: HTMLElement, key: string, label: string, ariaLabel: string, run: () => void, kind: 'primary' | 'secondary' | 'link' | 'icon' = 'secondary', icon?: ThemeIcon, disposables: DisposableStore = this.renderDisposables): Button { const button = disposables.add(new Button(parent, { ...defaultButtonStyles, - secondary: true, + secondary: kind !== 'primary', buttonSecondaryBackground: 'transparent', buttonSecondaryForeground: kind === 'link' ? 'var(--vscode-textLink-foreground)' : 'var(--vscode-foreground)', buttonSecondaryHoverBackground: 'var(--vscode-list-hoverBackground)', diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css index d57fa57b8919..316eb3159b26 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css @@ -33,6 +33,13 @@ gap: var(--vscode-spacing-size160); } +.customization-migration-dashboard .migration-page-title-actions { + display: flex; + align-items: center; + flex-shrink: 0; + gap: var(--vscode-spacing-size120); +} + .customization-migration-dashboard h1, .customization-migration-dashboard h2, .customization-migration-dashboard h3, @@ -53,7 +60,7 @@ } .customization-migration-dashboard .migration-intro { - margin: var(--vscode-spacing-size60) 0 var(--vscode-spacing-size240); + margin: var(--vscode-spacing-size60) 0 var(--vscode-spacing-size80); color: var(--vscode-descriptionForeground); } @@ -139,7 +146,7 @@ flex: 1 1 auto; min-height: 0; width: 100%; - margin-top: var(--vscode-spacing-size160); + margin-top: var(--vscode-spacing-size80); overflow: hidden; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 4378f8729c48..b853fbef8a13 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -727,9 +727,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ }); } - async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { + async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { const { type, displayName } = contribution; - await openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Editor }, chatOptions); + return openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Editor }, chatOptions); } }), // New chat in sidebar chat (+ button) @@ -749,9 +749,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ }); } - async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { + async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { const { type, displayName } = contribution; - await openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Sidebar }, chatOptions); + return openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Sidebar }, chatOptions); } }) ); @@ -1745,7 +1745,7 @@ export type NewChatSessionOpenOptions = { readonly replaceEditorForResource?: URI; }; -export async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatSessionOpenOptions, chatSendOptions?: NewChatSessionSendOptions): Promise { +export async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatSessionOpenOptions, chatSendOptions?: NewChatSessionSendOptions): Promise { const viewsService = accessor.get(IViewsService); const chatService = accessor.get(IChatService); const chatSessionService = accessor.get(IChatSessionsService); @@ -1759,6 +1759,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N // Determine resource to open const sessionResource = getResourceForNewChatSession(openOptions); + let openedSessionResource = sessionResource; // Stash any imported ("Continue in…") conversation before the session is // opened: opening can eagerly pre-create the backend session (via the chat @@ -1836,7 +1837,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N logService.error(`Failed to open '${openOptions.type}' chat session with openOptions: ${JSON.stringify(openOptions)}`, e); sessionsListSuppression?.dispose(); transitionProgress?.complete(); - return; + return undefined; } // Send initial prompt if provided @@ -1856,6 +1857,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N const result = await chatService.sendRequest(sessionResource, chatSendOptions.prompt, { agentIdSilent: openOptions.type, attachedContext }); const newSessionResource = result.kind === 'sent' || result.kind === 'rejected' ? result.newSessionResource : undefined; if (newSessionResource && !resources.isEqual(newSessionResource, sessionResource)) { + openedSessionResource = newSessionResource; switch (openOptions.position) { case ChatSessionPosition.Sidebar: { const view = await viewsService.openView(ChatViewId) as ChatViewPane; @@ -1885,6 +1887,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N // working indicator. sessionsListSuppression?.dispose(); transitionProgress?.complete(); + return openedSessionResource; } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationTelemetryService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationTelemetryService.ts index 9dec781f0fc3..1cc12fe14202 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationTelemetryService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationTelemetryService.ts @@ -3,12 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { disposableTimeout, RunOnceScheduler } from '../../../../../../base/common/async.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { dirname, isEqual } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { IChatService } from '../../chatService/chatService.js'; import { CustomizationMigrationFailureReason, CustomizationMigrationType, ICustomizationMigrationHint } from './customizationMigrationService.js'; export const ICustomizationMigrationTelemetryService = createDecorator('customizationMigrationTelemetryService'); +const AGENT_MIGRATION_BACKGROUND_TIMEOUT = 10 * 60 * 1000; + type CustomizationMigrationAction = | 'hintShown' | 'hintReviewClicked' @@ -17,6 +27,7 @@ type CustomizationMigrationAction = | 'migrationCategoryShown' | 'migrationOverviewClicked' | 'migrationCategoryClicked' + | 'agentMigrationClicked' | 'migrationClicked' | 'migrationCompleted' | 'backClicked' @@ -36,6 +47,8 @@ type CustomizationMigrationEvent = { requestedCount?: number; migratedCount?: number; failedCount?: number; + cancelled?: boolean; + timedOut?: boolean; migrationFailedReasons?: string; }; @@ -47,6 +60,8 @@ type CustomizationMigrationClassification = { requestedCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of customizations selected for migration.' }; migratedCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of customizations successfully migrated.' }; failedCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of customizations that failed to migrate.' }; + cancelled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the customization migration flow was cancelled before completion.' }; + timedOut?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the customization migration result was not produced within ten minutes after its session entered the background.' }; migrationFailedReasons?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A semicolon-separated list of bounded failure reason identifiers. Does not contain customization names, paths, content, or error messages.' }; owner: 'digitarald'; comment: 'Tracks aggregate customization migration impressions, actions, and outcomes without collecting customization names, paths, or content.'; @@ -73,17 +88,57 @@ export interface ICustomizationMigrationTelemetryService { hintShown(hint: ICustomizationMigrationHint): void; hintClicked(hint: ICustomizationMigrationHint, action: 'review' | 'dismiss'): void; pageShown(category?: CustomizationMigrationType): void; - actionClicked(action: 'migrationOverviewClicked' | 'migrationCategoryClicked' | 'backClicked' | 'destinationsClicked' | 'workspaceSkipped' | 'workspaceIncluded' | 'retryClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', category?: CustomizationMigrationType): void; + actionClicked(action: 'migrationOverviewClicked' | 'migrationCategoryClicked' | 'agentMigrationClicked' | 'backClicked' | 'destinationsClicked' | 'workspaceSkipped' | 'workspaceIncluded' | 'retryClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', category?: CustomizationMigrationType): void; migrationClicked(category: CustomizationMigrationType, requestedCount: number, migrationFlowId?: string): void; - migrationCompleted(category: CustomizationMigrationType, requestedCount: number, migratedCount: number, failedCount: number, failureReasons: readonly CustomizationMigrationFailureReason[], migrationFlowId?: string): void; + migrationCompleted(category: CustomizationMigrationType, requestedCount: number, migratedCount: number, failedCount: number, failureReasons: readonly CustomizationMigrationFailureReason[], migrationFlowId?: string, cancelled?: boolean, timedOut?: boolean): void; + watchAgentMigrationResult(resultResource: URI, migrationFlowId: string, inventoryCounts: ReadonlyMap, sessionResource: URI, lifecycle: ICustomizationMigrationSessionLifecycle): void; } -export class CustomizationMigrationTelemetryService implements ICustomizationMigrationTelemetryService { +export interface ICustomizationMigrationSessionLifecycle { + readonly onDidBackground: Event; + readonly onDidForeground: Event; +} + +interface ICustomizationMigrationAgentResultFile { + readonly version: 1; + readonly migrationFlowId: string; + readonly cancelled: boolean; + readonly results: readonly ICustomizationMigrationAgentResult[]; +} + +interface ICustomizationMigrationAgentResult { + readonly category: CustomizationMigrationType; + readonly scope: 'user' | 'workspace'; + readonly customizationType: 'agent' | 'instructions' | 'skill' | 'mcpServer'; + readonly outcome: 'migrated' | 'skipped' | 'failed'; + readonly count: number; +} + +export interface ICustomizationMigrationAgentOutcome { + readonly category: CustomizationMigrationType; + readonly requestedCount: number; + readonly migratedCount: number; + readonly failedCount: number; +} + +export interface ICustomizationMigrationAgentReport { + readonly cancelled: boolean; + readonly outcomes: readonly ICustomizationMigrationAgentOutcome[]; +} + +export class CustomizationMigrationTelemetryService extends Disposable implements ICustomizationMigrationTelemetryService { declare readonly _serviceBrand: undefined; + private readonly agentMigrationResultWatches = this._register(new DisposableMap()); + constructor( @ITelemetryService private readonly telemetryService: ITelemetryService, - ) { } + @IFileService private readonly fileService: IFileService, + @ILogService private readonly logService: ILogService, + @IChatService private readonly chatService: IChatService, + ) { + super(); + } hintComputed(hint: ICustomizationMigrationHint): void { for (const { type, count } of hint.counts) { @@ -103,7 +158,7 @@ export class CustomizationMigrationTelemetryService implements ICustomizationMig this.send({ action: category ? 'migrationCategoryShown' : 'migrationOverviewShown', category }); } - actionClicked(action: 'migrationOverviewClicked' | 'migrationCategoryClicked' | 'backClicked' | 'destinationsClicked' | 'workspaceSkipped' | 'workspaceIncluded' | 'retryClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', category?: CustomizationMigrationType): void { + actionClicked(action: 'migrationOverviewClicked' | 'migrationCategoryClicked' | 'agentMigrationClicked' | 'backClicked' | 'destinationsClicked' | 'workspaceSkipped' | 'workspaceIncluded' | 'retryClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', category?: CustomizationMigrationType): void { this.send({ action, category }); } @@ -116,7 +171,7 @@ export class CustomizationMigrationTelemetryService implements ICustomizationMig }); } - migrationCompleted(category: CustomizationMigrationType, requestedCount: number, migratedCount: number, failedCount: number, failureReasons: readonly CustomizationMigrationFailureReason[], migrationFlowId?: string): void { + migrationCompleted(category: CustomizationMigrationType, requestedCount: number, migratedCount: number, failedCount: number, failureReasons: readonly CustomizationMigrationFailureReason[], migrationFlowId?: string, cancelled?: boolean, timedOut?: boolean): void { const migrationFailedReasons = Array.from(new Set(failureReasons)).sort().join(';'); this.send({ action: 'migrationCompleted', @@ -125,10 +180,84 @@ export class CustomizationMigrationTelemetryService implements ICustomizationMig migratedCount, failedCount, ...(migrationFlowId ? { migrationFlowId } : {}), + ...(cancelled ? { cancelled: true } : {}), + ...(timedOut ? { timedOut: true } : {}), ...(migrationFailedReasons ? { migrationFailedReasons } : {}), }); } + watchAgentMigrationResult(resultResource: URI, migrationFlowId: string, inventoryCounts: ReadonlyMap, sessionResource: URI, lifecycle: ICustomizationMigrationSessionLifecycle): void { + const disposables = new DisposableStore(); + let active = true; + disposables.add(toDisposable(() => active = false)); + const finishIncompleteMigration = (cancelled: boolean, timedOut: boolean) => { + if (!active) { + return; + } + this.agentMigrationResultWatches.deleteAndDispose(migrationFlowId); + for (const [category] of inventoryCounts) { + this.migrationCompleted(category, 0, 0, 0, [], migrationFlowId, cancelled, timedOut); + } + }; + const backgroundTimeout = disposables.add(new MutableDisposable()); + disposables.add(lifecycle.onDidBackground(() => { + backgroundTimeout.value = disposableTimeout( + () => finishIncompleteMigration(false, true), + AGENT_MIGRATION_BACKGROUND_TIMEOUT, + ); + })); + disposables.add(lifecycle.onDidForeground(() => backgroundTimeout.clear())); + const reportResult = disposables.add(new RunOnceScheduler(async () => { + try { + const content = await this.fileService.readFile(resultResource, { limits: { size: 64 * 1024 } }); + if (!active) { + return; + } + const report = parseCustomizationMigrationAgentResult(content.value.toString(), migrationFlowId, inventoryCounts); + this.agentMigrationResultWatches.deleteAndDispose(migrationFlowId); + for (const outcome of report.outcomes) { + if (outcome.requestedCount > 0 || report.cancelled) { + this.migrationCompleted( + outcome.category, + outcome.requestedCount, + outcome.migratedCount, + outcome.failedCount, + [], + migrationFlowId, + report.cancelled, + ); + } + } + } catch (error) { + this.logService.warn('Could not read the agent customization migration result.', error); + } + }, 500)); + disposables.add(this.fileService.watch(dirname(resultResource))); + disposables.add(this.fileService.onDidFilesChange(event => { + if (event.affects(resultResource)) { + reportResult.schedule(); + } + })); + let cancelWhenStopped: (() => void) | undefined; + const model = this.chatService.getSession(sessionResource); + if (model) { + cancelWhenStopped = () => { + const response = model.lastRequest?.response; + if (response?.isCanceled || response?.result?.errorDetails?.code === 'canceled') { + finishIncompleteMigration(true, false); + } + }; + disposables.add(model.onDidChange(cancelWhenStopped)); + } + disposables.add(this.chatService.onDidDisposeSession(event => { + if (event.reason === 'cleared' && event.sessionResources.some(resource => isEqual(resource, sessionResource))) { + finishIncompleteMigration(true, false); + } + })); + this.agentMigrationResultWatches.set(migrationFlowId, disposables); + cancelWhenStopped?.(); + } + private send(event: CustomizationMigrationEvent): void { this.telemetryService.publicLog2('chat.customizationMigration', event); } @@ -140,4 +269,53 @@ export class CustomizationMigrationTelemetryService implements ICustomizationMig count: hint.counts.reduce((total, { count }) => total + count, 0), }); } + +} + +export function parseCustomizationMigrationAgentResult( + content: string, + migrationFlowId: string, + inventoryCounts: ReadonlyMap, +): ICustomizationMigrationAgentReport { + const resultFile = JSON.parse(content) as ICustomizationMigrationAgentResultFile; + if (resultFile.version !== 1 || resultFile.migrationFlowId !== migrationFlowId || typeof resultFile.cancelled !== 'boolean' || !Array.isArray(resultFile.results) || resultFile.results.length === 0 || !resultFile.results.every(isValidCustomizationMigrationAgentResult)) { + throw new Error('Invalid customization migration result file.'); + } + + const reportedCounts = new Map(); + const outcomesByCategory = new Map(); + for (const result of resultFile.results) { + const reportedCount = (reportedCounts.get(result.category) ?? 0) + result.count; + if (reportedCount > (inventoryCounts.get(result.category) ?? 0)) { + throw new Error('Customization migration result exceeds the supplied inventory.'); + } + reportedCounts.set(result.category, reportedCount); + + const previous = outcomesByCategory.get(result.category) ?? { + category: result.category, + requestedCount: 0, + migratedCount: 0, + failedCount: 0, + }; + outcomesByCategory.set(result.category, { + category: result.category, + requestedCount: previous.requestedCount + (result.outcome === 'skipped' ? 0 : result.count), + migratedCount: previous.migratedCount + (result.outcome === 'migrated' ? result.count : 0), + failedCount: previous.failedCount + (result.outcome === 'failed' ? result.count : 0), + }); + } + + return { + cancelled: resultFile.cancelled, + outcomes: [...outcomesByCategory.values()], + }; +} + +function isValidCustomizationMigrationAgentResult(result: ICustomizationMigrationAgentResult): boolean { + return Object.values(CustomizationMigrationType).includes(result.category) + && (result.scope === 'user' || result.scope === 'workspace') + && (result.customizationType === 'agent' || result.customizationType === 'instructions' || result.customizationType === 'skill' || result.customizationType === 'mcpServer') + && (result.outcome === 'migrated' || result.outcome === 'skipped' || result.outcome === 'failed') + && Number.isSafeInteger(result.count) + && result.count > 0; } diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index fd13940c786f..20fbabec6b0e 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -313,7 +313,7 @@ type NewAgentHostSessionSendOptions = Parameters[2]; // type picker command and the static sidebar/editor commands below. // Delegates to `openChatSession` so the session type picker, context keys, // and welcome flows all stay in sync with the dynamic per-agent path. -async function openNewAgentHostSession(accessor: ServicesAccessor, position: ChatSessionPosition, chatSendOptions?: NewAgentHostSessionSendOptions): Promise { +async function openNewAgentHostSession(accessor: ServicesAccessor, position: ChatSessionPosition, chatSendOptions?: NewAgentHostSessionSendOptions): Promise { // Snapshot the services we need synchronously — `accessor` is only valid // before the first `await`. Use the instantiation service to mint a fresh // accessor for the downstream `openChatSession` call. diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index 2f380a1a1029..93e29ef6bd93 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -437,6 +437,7 @@ suite('aiCustomizationManagementEditor', () => { actionClicked: () => { }, migrationClicked: () => { }, migrationCompleted: () => { }, + watchAgentMigrationResult: () => { }, }; editor.dialogService = { confirm: async () => ({ confirmed: false }), diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts index 8457b7e427ae..1921902dab60 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts @@ -19,7 +19,7 @@ import { PromptFileSource, PromptsType } from '../../../common/promptSyntax/prom import { CustomizationMigrationType, FileCustomizationMigrationFailureReason, IMcpServerCustomizationMigrationCandidate, McpServerCustomizationMigrationFailureReason, type MigratableConfiguration } from '../../../common/promptSyntax/service/customizationMigrationService.js'; import { PromptsStorage, type IPromptPath } from '../../../common/promptSyntax/service/promptsService.js'; import { ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; -import { createSkillFileUri, migrateCustomizations, migratePromptFileToSkill, resolveWorkspaceMigrationTargetFolder, type CustomizationMigrationTargetFolders } from '../../../browser/aiCustomization/customizationMigration.js'; +import { createCustomizationMigrationAgentPrompt, createSkillFileUri, migrateCustomizations, migratePromptFileToSkill, resolveWorkspaceMigrationTargetFolder, type CustomizationMigrationTargetFolders } from '../../../browser/aiCustomization/customizationMigration.js'; import { CUSTOMIZATION_MIGRATION_CATEGORIES, CustomizationMigrationCategoryId, getCustomizationMigrationCategory } from '../../../browser/aiCustomization/customizationMigrationCategories.js'; class DeleteFailingFileSystemProvider extends InMemoryFileSystemProvider { @@ -130,6 +130,61 @@ suite('customizationMigration', () => { }); }); + test('builds an agent prompt from discovered sources and harness-reported targets', () => { + const recoveryBundleFolder = URI.file('/recovery/vscode-customization-migration'); + const prompt = createCustomizationMigrationAgentPrompt( + { id: 'agent-host-copilotcli', label: 'Copilot' }, + 'migration-flow-id', + recoveryBundleFolder, + [ + { + category: CustomizationMigrationType.PromptFiles, + customization: { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + }, + { + category: CustomizationMigrationType.McpServers, + customization: { + type: CustomizationMigrationType.McpServers, + storage: PromptsStorage.user, + id: 'server', + name: 'Server', + sourceUri: URI.file('/profile/mcp.json'), + targetUri: URI.file('/home/.copilot/mcp-config.json'), + projectedConfiguration: { type: McpServerType.LOCAL, command: 'node' }, + }, + }, + { + category: CustomizationMigrationType.ConfiguredLocations, + customization: { uri: URI.file('/workspace/custom/review.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, + }, + ], + new Map([ + [PromptsType.skill, [ + { uri: URI.file('/workspace/.github/skills'), label: 'Workspace skills', source: 'local' }, + { uri: URI.file('/home/.copilot/skills'), label: 'User skills', source: 'user' }, + ]], + ]), + ); + + assert.strictEqual(prompt.replace(/\\/g, '/'), [ + '/migrate-customizations', + '', + 'Selected harness: Copilot (agent-host-copilotcli)', + 'Migration telemetry flow: migration-flow-id', + 'Recovery bundle folder: file:///recovery/vscode-customization-migration', + `Recovery bundle filesystem path: ${recoveryBundleFolder.path}`, + '', + 'Customizations that need migration:', + '- promptFiles: prompt (local): file:///workspace/.github/prompts/review.prompt.md', + '- mcpServers: MCP server "Server" (user): file:///profile/mcp.json -> file:///home/.copilot/mcp-config.json', + '- configuredLocations: instructions (local): file:///workspace/custom/review.instructions.md', + '', + 'Valid target folders reported by the selected harness:', + '- skill (local, Workspace skills): file:///workspace/.github/skills', + '- skill (user, User skills): file:///home/.copilot/skills', + ].join('\n')); + }); + test('configured locations copy explains harness discovery and setting scope', () => { const category = getCustomizationMigrationCategory(CustomizationMigrationCategoryId.ConfiguredLocations); const agent: IPromptPath = { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts index 7c8a3202ecfc..7606a4fe2ab8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts @@ -33,6 +33,7 @@ suite('CustomizationMigrationDashboard', () => { actionClicked: (action, categoryId) => telemetryActions.push(categoryId ? `${action}:${categoryId}` : action), configureLocations: () => { }, dismissResult: () => { }, + migrateWithAgent: () => { }, migrateCategory: () => { }, setItemSelected: () => { }, showItemActions: () => { }, @@ -107,12 +108,23 @@ suite('CustomizationMigrationDashboard', () => { const actions: string[] = []; const { parent, dashboard, telemetryActions } = createDashboard({ configureLocations: (id, storage) => actions.push(`destinations:${id}:${storage}`), + migrateWithAgent: () => actions.push('agent'), migrateCategory: (id, storage) => actions.push(`migrate:${id}:${storage}`), }); + + test('hides agent migration for unsupported harnesses', () => { + const { parent, dashboard } = createDashboard(); + dashboard.showOverview({ ...overview(), supportsAgentMigration: false }); + + assert.strictEqual(parent.querySelector('[aria-label="Start an agent-guided customization migration"]'), null); + }); dashboard.showOverview(overview()); dashboard.focus(); const initialFocus = document.activeElement?.getAttribute('aria-label'); - button(parent, 'Migrate Convert Prompt to Skills (Workspace)').click(); + const agentMigrationButton = button(parent, 'Start an agent-guided customization migration'); + const migrateButton = button(parent, 'Migrate Convert Prompt to Skills (Workspace)'); + agentMigrationButton.click(); + migrateButton.click(); button(parent, 'Migrate Convert Prompt to Skills (User)').click(); button(parent, 'Migrate MCP Servers (Workspace)').click(); button(parent, 'Migrate User Data (User)').click(); @@ -127,6 +139,10 @@ suite('CustomizationMigrationDashboard', () => { sources: [...parent.querySelectorAll('.migration-tree-item-source')].map(element => element.textContent), changes: [...parent.querySelectorAll('.migration-tree-item-changes')].filter(element => (element as HTMLElement).style.display !== 'none').map(element => element.textContent), checklistCopy: parent.textContent?.includes('Your migration checklist'), + intro: parent.querySelector('.migration-intro')?.textContent, + agentButtonInTitleRow: agentMigrationButton.parentElement?.classList.contains('migration-page-title-actions'), + agentButtonIsSecondary: agentMigrationButton.classList.contains('migration-secondary-button'), + migrateButtonIsSecondary: migrateButton.classList.contains('migration-secondary-button'), initialFocus, focus: document.activeElement?.getAttribute('aria-label'), actions, @@ -139,10 +155,14 @@ suite('CustomizationMigrationDashboard', () => { sources: ['.github/prompts/build.prompt.md', '~/.copilot/prompts/release.prompt.md', '~/.copilot/prompts/triage.prompt.md', '.vscode/mcp.json', '~/.copilot/agents/planner.agent.md', '~/.copilot/instructions/review.instructions.md', 'team/rules.instructions.md'], changes: ['The gallery property will be removed.'], checklistCopy: false, + intro: 'Some of your agent customizations need an update to keep working. Use Migrate to have VS Code update selected customizations, or Migrate with Agent for a guided migration in chat. Agent migration uses credits.', + agentButtonInTitleRow: true, + agentButtonIsSecondary: true, + migrateButtonIsSecondary: true, initialFocus: 'Migrate Convert Prompt to Skills (Workspace)', focus: 'Change destination for user prompt migrations', - actions: ['migrate:promptFiles:local', 'migrate:promptFiles:user', 'migrate:mcpServers:local', 'migrate:userData:user', 'migrate:configuredLocations:local', 'destinations:promptFiles:user'], - telemetryActions: ['migrationCategoryClicked:promptFiles', 'migrationCategoryClicked:promptFiles', 'migrationCategoryClicked:mcpServers', 'migrationCategoryClicked:userData', 'migrationCategoryClicked:configuredLocations', 'destinationsClicked'], + actions: ['agent', 'migrate:promptFiles:local', 'migrate:promptFiles:user', 'migrate:mcpServers:local', 'migrate:userData:user', 'migrate:configuredLocations:local', 'destinations:promptFiles:user'], + telemetryActions: ['agentMigrationClicked', 'migrationCategoryClicked:promptFiles', 'migrationCategoryClicked:promptFiles', 'migrationCategoryClicked:mcpServers', 'migrationCategoryClicked:userData', 'migrationCategoryClicked:configuredLocations', 'destinationsClicked'], }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationTelemetryService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationTelemetryService.test.ts index bbb76919a6bb..25ec1796c8ac 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationTelemetryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationTelemetryService.test.ts @@ -4,10 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mock } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../../../../platform/files/common/files.js'; +import { NullLogService } from '../../../../../../../platform/log/common/log.js'; import { NullTelemetryServiceShape } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IChatService } from '../../../../common/chatService/chatService.js'; import { CustomizationMigrationType, FileCustomizationMigrationFailureReason } from '../../../../common/promptSyntax/service/customizationMigrationService.js'; -import { CustomizationMigrationTelemetryService } from '../../../../common/promptSyntax/service/customizationMigrationTelemetryService.js'; +import { CustomizationMigrationTelemetryService, parseCustomizationMigrationAgentResult } from '../../../../common/promptSyntax/service/customizationMigrationTelemetryService.js'; class TestTelemetryService extends NullTelemetryServiceShape { readonly events: { readonly name: string; readonly data: unknown }[] = []; @@ -20,11 +24,11 @@ class TestTelemetryService extends NullTelemetryServiceShape { } suite('CustomizationMigrationTelemetryService', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); test('reports migration impressions, actions, and outcomes', () => { const telemetryService = new TestTelemetryService(); - const service = new CustomizationMigrationTelemetryService(telemetryService); + const service = store.add(new CustomizationMigrationTelemetryService(telemetryService, new class extends mock() { }, new NullLogService(), new class extends mock() { })); const hint = { migrationFlowId: 'migration-flow-id', message: 'Migration hint', @@ -38,12 +42,15 @@ suite('CustomizationMigrationTelemetryService', () => { service.pageShown(); service.pageShown(CustomizationMigrationType.PromptFiles); service.actionClicked('migrationCategoryClicked', CustomizationMigrationType.PromptFiles); + service.actionClicked('agentMigrationClicked'); service.migrationClicked(CustomizationMigrationType.PromptFiles, 3, hint.migrationFlowId); service.migrationCompleted(CustomizationMigrationType.PromptFiles, 3, 2, 1, [ FileCustomizationMigrationFailureReason.TargetWriteFailed, FileCustomizationMigrationFailureReason.TargetWriteFailed, FileCustomizationMigrationFailureReason.RollbackFailed, ], hint.migrationFlowId); + service.migrationCompleted(CustomizationMigrationType.McpServers, 0, 0, 0, [], hint.migrationFlowId, true); + service.migrationCompleted(CustomizationMigrationType.UserData, 0, 0, 0, [], hint.migrationFlowId, false, true); assert.deepStrictEqual(telemetryService.events, [ { name: 'chat.customizationMigrationAssessment', data: { migrationFlowId: 'migration-flow-id', category: 'promptFiles', count: 3 } }, @@ -53,8 +60,34 @@ suite('CustomizationMigrationTelemetryService', () => { { name: 'chat.customizationMigration', data: { action: 'migrationOverviewShown', category: undefined } }, { name: 'chat.customizationMigration', data: { action: 'migrationCategoryShown', category: 'promptFiles' } }, { name: 'chat.customizationMigration', data: { action: 'migrationCategoryClicked', category: 'promptFiles' } }, + { name: 'chat.customizationMigration', data: { action: 'agentMigrationClicked', category: undefined } }, { name: 'chat.customizationMigration', data: { action: 'migrationClicked', category: 'promptFiles', migrationFlowId: 'migration-flow-id', requestedCount: 3 } }, { name: 'chat.customizationMigration', data: { action: 'migrationCompleted', category: 'promptFiles', migrationFlowId: 'migration-flow-id', requestedCount: 3, migratedCount: 2, failedCount: 1, migrationFailedReasons: 'rollbackFailed;targetWriteFailed' } }, + { name: 'chat.customizationMigration', data: { action: 'migrationCompleted', category: 'mcpServers', migrationFlowId: 'migration-flow-id', requestedCount: 0, migratedCount: 0, failedCount: 0, cancelled: true } }, + { name: 'chat.customizationMigration', data: { action: 'migrationCompleted', category: 'userData', migrationFlowId: 'migration-flow-id', requestedCount: 0, migratedCount: 0, failedCount: 0, timedOut: true } }, ]); }); + + test('parses aggregate outcomes from an agent migration result', () => { + const report = parseCustomizationMigrationAgentResult(JSON.stringify({ + version: 1, + migrationFlowId: 'migration-flow-id', + cancelled: true, + results: [ + { category: 'promptFiles', scope: 'workspace', customizationType: 'skill', outcome: 'migrated', count: 2 }, + { category: 'promptFiles', scope: 'user', customizationType: 'skill', outcome: 'failed', count: 1 }, + { category: 'promptFiles', scope: 'user', customizationType: 'skill', outcome: 'skipped', count: 1 }, + ], + }), 'migration-flow-id', new Map([[CustomizationMigrationType.PromptFiles, 4]])); + + assert.deepStrictEqual(report, { + cancelled: true, + outcomes: [{ + category: CustomizationMigrationType.PromptFiles, + requestedCount: 3, + migratedCount: 2, + failedCount: 1, + }], + }); + }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/customizationMigrationDashboard.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/customizationMigrationDashboard.fixture.ts index 58f870121b8e..096d6deb8813 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/customizationMigrationDashboard.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/customizationMigrationDashboard.fixture.ts @@ -163,6 +163,7 @@ function renderDashboard(ctx: ComponentFixtureContext, width: number, state: 'ov const dashboard = disposableStore.add(instantiationService.createInstance(CustomizationMigrationDashboard, container, { actionClicked: () => { }, configureLocations: () => { }, + migrateWithAgent: () => { }, migrateCategory: () => { }, setItemSelected: () => { }, showItemActions: () => { }, From 53df301065ee468f65325603a084d1a1c25019f1 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Sun, 4 Oct 2026 22:21:52 +0200 Subject: [PATCH 16/49] Report session customization telemetry (#339291) * Report session customization telemetry Collect customization inventory after the first request accepted for a new session, using the shared chat service so both the editor and Agents windows are covered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * remove cancelation timeout out of whenCustomizationsReady * update --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentCustomizationItemProvider.ts | 14 +- .../agentHostCustomizationService.ts | 47 ++-- .../customizationsTelemetryService.ts | 266 ++++++++++++++++++ .../chat/browser/chat.shared.contribution.ts | 3 + .../agentCustomizationItemProvider.test.ts | 35 +++ .../agentHostCustomizationService.test.ts | 46 ++- .../customizationsTelemetryService.test.ts | 217 ++++++++++++++ ...emoteAgentHostCustomizationHarness.test.ts | 2 +- 8 files changed, 576 insertions(+), 54 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/customizationsTelemetryService.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationsTelemetryService.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index 172ff311b4d8..4ed879c4ca76 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceCancellation, raceTimeout } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; @@ -33,6 +34,7 @@ import { getAgentHostPluginEnablementActions } from '../../agentPluginActions.js const REMOTE_HOST_GROUP = 'remote-host'; const REMOTE_CLIENT_GROUP = 'remote-client'; +const SOURCE_FOLDER_CUSTOMIZATION_READINESS_TIMEOUT_MS = 2000; type PluginMeta = { item: ICustomizationItem; nonce: string | undefined; status: ReturnType; statusMessage: string | undefined; enabled: boolean | undefined; disabledReason: CustomizationDisabledReason | undefined; childGroupKey: string; isBundleItem: boolean; pluginLabel: string | undefined }; @@ -44,7 +46,9 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto /** Cache: pluginUri → last expansion (keyed by nonce and label so we re-fetch on content or display-name changes). */ private readonly _expansionCache = new ResourceMap<{ nonce: string | undefined; pluginLabel: string | undefined; children: readonly ICustomizationItem[] }>(); + private readonly _sourceFolderReadiness = new ResourceMap>(); private readonly _contentExpander: AgentCustomizationContentExpander; + protected readonly _sourceFolderReadinessTimeoutMs: number = SOURCE_FOLDER_CUSTOMIZATION_READINESS_TIMEOUT_MS; private _draftCustomAgents: IObservable | undefined; private _draftCustomizations: IObservable | undefined; @@ -196,7 +200,15 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto // One-shot callers (the migration hint) must not read the empty // placeholder a still-loading session reports, or they conclude there is // nothing to migrate. - await this._customAgentsService.whenCustomizationsReady(sessionResource, token); + let readiness = this._sourceFolderReadiness.get(sessionResource); + if (!readiness) { + readiness = raceTimeout( + this._customAgentsService.whenCustomizationsReady(sessionResource), + this._sourceFolderReadinessTimeoutMs, + ); + this._sourceFolderReadiness.set(sessionResource, readiness); + } + await raceCancellation(readiness, token); const workingDirectories = this._customAgentsService.getWorkingDirectories(sessionResource); const clientWorkingDirectories = this._customAgentsService.getClientWorkingDirectoryUris(sessionResource); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index 8d84ec8aebf0..9628bf6b0490 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../../base/common/uri.js'; -import { raceCancellation, raceTimeout } from '../../../../../../base/common/async.js'; +import { raceCancellation } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { StringSHA1 } from '../../../../../../base/common/hash.js'; @@ -49,11 +49,11 @@ export interface IAgentHostCustomizationService { getCustomizations(sessionResource: URI): readonly Customization[]; /** - * Waits up to two seconds for {@link getCustomizations} to reflect the session's first state snapshot; it may resolve earlier on cancellation, failure, or when no agent-host session exists. - * The wait is shared per session, so repeated calls observe one deadline rather than restarting it, and resolve immediately once it has elapsed. - * Intended for one-shot reads; reactive callers should continue listening to {@link onDidChangeCustomizations}. + * Waits for {@link getCustomizations} to reflect the session's first state snapshot, or until the caller cancels or the subscription fails. + * The readiness wait is shared per session; callers choose their own cancellation or timeout policy. + * Returns whether a session-state snapshot is available. */ - whenCustomizationsReady(sessionResource: URI, token?: CancellationToken): Promise; + whenCustomizationsReady(sessionResource: URI, token?: CancellationToken): Promise; /** * The harness-owned decision about the multi-root Folder picker for a @@ -121,8 +121,8 @@ export class NullAgentHostCustomizationService implements IAgentHostCustomizatio getCustomizations(_sessionResource: URI): readonly Customization[] { return []; } - whenCustomizationsReady(_sessionResource: URI, _token?: CancellationToken): Promise { - return Promise.resolve(); + whenCustomizationsReady(_sessionResource: URI, _token?: CancellationToken): Promise { + return Promise.resolve(true); } getFolderPickerDecision(_sessionResource: URI): ISessionFolderPickerDecision | undefined { return undefined; @@ -212,8 +212,8 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i * Targets resolved by this base are backed by already-materialized provider * state, so a snapshot is available as soon as the target resolves. */ - whenCustomizationsReady(_sessionResource: URI, _token?: CancellationToken): Promise { - return Promise.resolve(); + whenCustomizationsReady(_sessionResource: URI, _token?: CancellationToken): Promise { + return Promise.resolve(false); } getFolderPickerDecision(sessionResource: URI): ISessionFolderPickerDecision | undefined { @@ -478,11 +478,9 @@ export function getPresentableMcpServerCustomizations(customizations: readonly C return entries.filter(entry => entry.isTopLevel || !topLevelNames.has(entry.server.name)); } -/** - * Upper bound on how long {@link WorkbenchAgentHostCustomizationService.whenCustomizationsReady} - * waits for a session's first state snapshot. - */ -const SESSION_STATE_SNAPSHOT_TIMEOUT_MS = 2000; +function hasSessionSnapshot(subscription: IAgentSubscription): boolean { + return subscription.value !== undefined && !(subscription.value instanceof Error); +} /** * A live session-state subscription plus the memoized readiness wait shared by @@ -500,9 +498,6 @@ export class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCus private readonly _sessionStateSubscriptions = this._register(new DisposableResourceMap()); - /** Overridable so tests can exercise the timeout without real-time waits. */ - protected readonly _snapshotTimeoutMs: number = SESSION_STATE_SNAPSHOT_TIMEOUT_MS; - constructor( @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, @IAgentHostUntitledProvisionalSessionService private readonly _provisionalSessionService: IAgentHostUntitledProvisionalSessionService, @@ -648,28 +643,24 @@ export class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCus * Session state arrives asynchronously over the protocol, so a freshly * created subscription reports `undefined` until its first snapshot lands. * - * The wait is memoized per subscription so that the many source-folder - * queries behind a single migration hint observe one shared deadline rather - * than restarting it per prompt type. It is bounded because the chat request - * path blocks on this before sending the user's message: once it elapses, - * callers fall back to the current (possibly empty) snapshot rather than - * stalling the send again on every subsequent query. + * The wait is memoized per subscription so that callers can apply their own + * cancellation or timeout policy without affecting other consumers. */ - override async whenCustomizationsReady(sessionResource: URI, token: CancellationToken = CancellationToken.None): Promise { + override async whenCustomizationsReady(sessionResource: URI, token: CancellationToken = CancellationToken.None): Promise { const target = this._resolveSessionTarget(sessionResource); if (!target) { - return; + return false; } const entry = this._ensureSessionStateSubscription(sessionResource, target); - // An `Error` value counts as resolved: the subscription settled, just not with a snapshot. if (!entry || entry.sub.value !== undefined) { - return; + return !!entry && hasSessionSnapshot(entry.sub); } // Each caller races the shared wait against its own token, so one // cancellation cannot settle the wait for the others. entry.readiness ??= this._awaitFirstSnapshot(entry.sub); await raceCancellation(entry.readiness, token); + return hasSessionSnapshot(entry.sub); } private async _awaitFirstSnapshot(subscription: IAgentSubscription): Promise { @@ -682,7 +673,7 @@ export class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCus store.add(onDidError(() => resolve())); } }); - await raceTimeout(firstSnapshot, this._snapshotTimeoutMs); + await firstSnapshot; } finally { store.dispose(); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationsTelemetryService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationsTelemetryService.ts new file mode 100644 index 000000000000..256e59457b42 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationsTelemetryService.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { isAgentBuiltinCustomizationUri } from '../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; +import { CustomizationType, type ChildCustomization, type PluginCustomization } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { IAgentHostActiveClientService } from '../agentSessions/agentHost/agentHostActiveClientService.js'; +import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; +import { isAgentHostSessionResource } from '../../common/chatSessionsService.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { AICustomizationSource, AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; +import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../services/agentHost/common/agentHostFileSystemService.js'; + +export const ICustomizationsTelemetryService = createDecorator('customizationsTelemetryService'); + +export interface ICustomizationsTelemetryService { + readonly _serviceBrand: undefined; + reportNewSession(sessionResource: URI): void; +} + +type CustomizationTelemetryType = 'agent' | 'instructions' | 'prompt' | 'skill' | 'hook' | 'mcpServer' | 'plugin'; + +type CustomizationsDefinedEvent = { + customizationType: CustomizationTelemetryType; + userCount: number; + workspaceCount: number; + extensionCount: number; + pluginCount: number; + builtinCount: number; +}; + +type CustomizationsDefinedClassification = { + owner: 'aeschli'; + comment: 'Tracks how many customizations of each type and source are defined when a new Agent Host chat session is first used.'; + customizationType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The bounded customization type being counted.' }; + userCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of user-level customizations of this type.' }; + workspaceCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of workspace-level customizations of this type.' }; + extensionCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of extension-contributed customizations of this type.' }; + pluginCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of plugin-contributed customizations of this type.' }; + builtinCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of built-in customizations of this type.' }; +}; + +const customizationTypes: readonly CustomizationTelemetryType[] = [ + 'agent', + 'instructions', + 'prompt', + 'skill', + 'hook', + 'mcpServer', + 'plugin', +]; + +type CustomizationSourceCounts = Omit; + +export class CustomizationsTelemetryService implements ICustomizationsTelemetryService { + declare readonly _serviceBrand: undefined; + + private readonly reportedSessions = new Set(); + + constructor( + @IAgentHostCustomizationService private readonly agentHostCustomizationService: IAgentHostCustomizationService, + @IAgentHostActiveClientService private readonly agentHostActiveClientService: IAgentHostActiveClientService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @ILogService private readonly logService: ILogService, + ) { } + + reportNewSession(sessionResource: URI): void { + if (!isAgentHostSessionResource(sessionResource)) { + return; + } + const key = sessionResource.toString(); + if (this.reportedSessions.has(key)) { + return; + } + this.reportedSessions.add(key); + void this.report(sessionResource).catch(error => { + this.reportedSessions.delete(key); + this.logService.error('Failed to collect customizations telemetry for a new session', error); + }); + } + + private async report(sessionResource: URI): Promise { + const counts = createEmptyCounts(); + const workingDirectories = this.agentHostCustomizationService.getWorkingDirectories(sessionResource); + for (const customization of this.agentHostCustomizationService.getCustomizations(sessionResource)) { + if (customization.type === CustomizationType.Plugin) { + const syntheticBundle = URI.parse(customization.uri).scheme === SYNCED_CUSTOMIZATION_SCHEME; + if (!syntheticBundle) { + incrementSource(counts.plugin, AICustomizationSources.plugin); + } + for (const child of customization.children ?? []) { + const type = toTelemetryType(child.type); + if (type && type !== 'mcpServer') { + const source = syntheticBundle + ? this.getSyncedChildSource(customization, child) + : AICustomizationSources.plugin; + incrementSource(counts[type], source); + } + } + } else if (customization.type === CustomizationType.Directory) { + for (const child of customization.children ?? []) { + const type = toTelemetryType(child.type); + if (type && type !== 'mcpServer') { + incrementSource(counts[type], getDirectoryChildSource(child, workingDirectories)); + } + } + } + } + + const workspaceRoots = this.agentHostCustomizationService.getClientWorkingDirectoryUris(sessionResource); + for (const server of this.agentHostCustomizationService.getMcpServers(sessionResource)) { + switch (server.source) { + case 'workspace': + counts.mcpServer.workspaceCount++; + break; + case 'plugin': + counts.mcpServer.pluginCount++; + break; + case 'builtin': + case 'managed': + counts.mcpServer.builtinCount++; + break; + case 'user': + counts.mcpServer.userCount++; + break; + case undefined: { + const sourceUri = server.sourceUri; + if (server.isPluginProvided) { + counts.mcpServer.pluginCount++; + } else if (sourceUri && workspaceRoots.some(root => isEqualOrParent(sourceUri, root))) { + counts.mcpServer.workspaceCount++; + } else { + counts.mcpServer.userCount++; + } + break; + } + } + } + + for (const customizationType of customizationTypes) { + this.telemetryService.publicLog2('agents/customizationsDefined', { + customizationType, + ...counts[customizationType], + }); + } + } + + private getSyncedChildSource(plugin: PluginCustomization, child: ChildCustomization): AICustomizationSource { + const pluginUri = URI.parse(plugin.uri); + const childUri = URI.parse(child.uri); + const syncedUri = child.type === CustomizationType.Skill + ? URI.joinPath(pluginUri, 'skills', basename(dirname(childUri)), basename(childUri)) + : URI.joinPath(pluginUri, getPluginDirectory(child.type), basename(childUri)); + return this.agentHostActiveClientService.getOrigin(syncedUri)?.source ?? AICustomizationSources.plugin; + } +} + +export class CustomizationsTelemetryContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.customizationsTelemetry'; + + constructor( + @IChatService chatService: IChatService, + @ICustomizationsTelemetryService customizationsTelemetryService: ICustomizationsTelemetryService, + ) { + super(); + this._register(chatService.onDidAcceptRequest(event => { + if (!event.isNewSession) { + return; + } + + const model = chatService.getSession(event.chatSessionResource); + if (!model) { + return; + } + const requestId = model.lastRequest?.id; + this._register(Event.once(Event.filter(model.onDidChange, change => + change.kind === 'completedRequest' && (!requestId || change.request.id === requestId) + ))(() => customizationsTelemetryService.reportNewSession(event.chatSessionResource))); + })); + } +} + +function createEmptyCounts(): Record { + return Object.fromEntries(customizationTypes.map(type => [type, { + userCount: 0, + workspaceCount: 0, + extensionCount: 0, + pluginCount: 0, + builtinCount: 0, + }])) as Record; +} + +function toTelemetryType(type: ChildCustomization['type']): CustomizationTelemetryType | undefined { + switch (type) { + case CustomizationType.Agent: + return 'agent'; + case CustomizationType.Rule: + return 'instructions'; + case CustomizationType.Prompt: + return 'prompt'; + case CustomizationType.Skill: + return 'skill'; + case CustomizationType.Hook: + return 'hook'; + case CustomizationType.McpServer: + return 'mcpServer'; + default: + return undefined; + } +} + +function getPluginDirectory(type: Exclude): string { + switch (type) { + case CustomizationType.Agent: + return 'agents'; + case CustomizationType.Rule: + return 'rules'; + case CustomizationType.Prompt: + return 'commands'; + case CustomizationType.Hook: + return 'hooks'; + case CustomizationType.McpServer: + return ''; + } +} + +function getDirectoryChildSource(child: ChildCustomization, workingDirectories: readonly string[]): AICustomizationSource { + const childUri = URI.parse(child.uri); + if (isAgentBuiltinCustomizationUri(childUri)) { + return AICustomizationSources.builtin; + } + if (childUri.scheme === Schemas.file && workingDirectories.some(root => isEqualOrParent(childUri, URI.parse(root)))) { + return AICustomizationSources.local; + } + return AICustomizationSources.user; +} + +function incrementSource(counts: CustomizationSourceCounts, source: AICustomizationSource): void { + switch (source) { + case AICustomizationSources.local: + counts.workspaceCount++; + break; + case AICustomizationSources.user: + counts.userCount++; + break; + case AICustomizationSources.extension: + counts.extensionCount++; + break; + case AICustomizationSources.plugin: + counts.pluginCount++; + break; + case AICustomizationSources.builtin: + counts.builtinCount++; + break; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 018ab2f94f6e..d00df7a6b381 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -138,6 +138,7 @@ import { AgentHostChatDebugContribution } from './chatDebug/agentHostChatDebugPr import { ChatDebugEditor } from './chatDebug/chatDebugEditor.js'; import { ChatDebugEditorInput, ChatDebugEditorInputSerializer } from './chatDebug/chatDebugEditorInput.js'; import { ChatGoalSummaryService, IChatGoalSummaryService } from './chatGoalSummaryService.js'; +import { CustomizationsTelemetryContribution, CustomizationsTelemetryService, ICustomizationsTelemetryService } from './aiCustomization/customizationsTelemetryService.js'; import { ChatSubmitRequestHandlerService, IChatSubmitRequestHandlerService } from './chatSubmitRequestHandlerService.js'; import { PromptsDebugContribution } from './promptsDebugContribution.js'; import { PromptLanguageFeaturesProvider } from './promptSyntax/promptFileContributions.js'; @@ -3281,6 +3282,7 @@ registerWorkbenchContribution2(UsagesToolContribution.ID, UsagesToolContribution registerWorkbenchContribution2(RenameToolContribution.ID, RenameToolContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatForegroundSessionCountContribution.ID, ChatForegroundSessionCountContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(CustomizationsTelemetryContribution.ID, CustomizationsTelemetryContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(HookSchemaAssociationContribution.ID, HookSchemaAssociationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored); @@ -3394,6 +3396,7 @@ registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesSe registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); registerSingleton(ICustomizationMigrationService, CustomizationMigrationService, InstantiationType.Delayed); registerSingleton(ICustomizationMigrationTelemetryService, CustomizationMigrationTelemetryService, InstantiationType.Delayed); +registerSingleton(ICustomizationsTelemetryService, CustomizationsTelemetryService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts index 6306091045d0..16ee0be49976 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts @@ -190,6 +190,41 @@ suite('AgentCustomizationItemProvider', () => { }]); }); + test('bounds and shares the source folder readiness wait', async () => { + class TestCustomizationService extends NullAgentHostCustomizationService { + readinessCalls = 0; + + override whenCustomizationsReady(): Promise { + this.readinessCalls++; + return new Promise(() => { }); + } + } + class TestAgentCustomizationItemProvider extends AgentCustomizationItemProvider { + protected override readonly _sourceFolderReadinessTimeoutMs = 1; + } + + const customizationService = new TestCustomizationService(); + const provider = disposables.add(new TestAgentCustomizationItemProvider( + 'local', + undefined, + undefined, + upcastPartial({}), + new NullLogService(), + customizationService, + makePromptsService(), + )); + const sessionResource = URI.parse('agent-host-copilotcli:///session'); + + const first = await provider.provideSourceFolders(sessionResource, PromptsType.agent, CancellationToken.None); + const second = await provider.provideSourceFolders(sessionResource, PromptsType.instructions, CancellationToken.None); + + assert.deepStrictEqual({ first, second, readinessCalls: customizationService.readinessCalls }, { + first: [], + second: [], + readinessCalls: 1, + }); + }); + test('classifies a directory child by its real file URI when its container is synthetic', async () => { const skillUri = 'file:///workspace/.agents/skills/launch/SKILL.md'; const customizations: Customization[] = [{ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts index dcc4ae707e9c..d9b2583a8c61 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { timeout } from '../../../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { IReference } from '../../../../../../base/common/lifecycle.js'; @@ -723,10 +724,6 @@ suite('WorkbenchAgentHostCustomizationService', () => { } function createReadinessSut(provider = 'copilot') { - /** Keeps the bounded wait short so timeout coverage costs no real time. */ - class TestTimeoutCustomizationService extends WorkbenchAgentHostCustomizationService { - protected override readonly _snapshotTimeoutMs = 20; - } const sessionResource = URI.parse('untitled:chat'); const backendSession = URI.parse(`${provider}:/session`); const subscription = store.add(new LiveSessionSubscription()); @@ -755,7 +752,7 @@ suite('WorkbenchAgentHostCustomizationService', () => { getChannelDescriptor: () => undefined, showChannel: async () => { }, }); - const service = store.add(new TestTimeoutCustomizationService( + const service = store.add(new WorkbenchAgentHostCustomizationService( new class extends mock() { override readonly ambientConnection = connection; }(), @@ -844,28 +841,29 @@ suite('WorkbenchAgentHostCustomizationService', () => { assert.strictEqual(resolved, true); }); - test('whenCustomizationsReady shares one bounded wait across every prompt-type query', async () => { - const { service, subscription, sessionResource } = createReadinessSut(); - - // `createFileMigration` queries source folders once per target prompt - // type, sequentially, so a never-hydrating subscription must cost one - // deadline for the whole hint rather than one per type. Counting - // listeners keeps this deterministic; a wall-clock bound would be flaky. - // The expected two are the subscription entry's own listener plus the - // single shared readiness wait; the point is that it stops growing. - await service.whenCustomizationsReady(sessionResource); + test('whenCustomizationsReady shares readiness while callers control cancellation', async () => { + const { service, subscription, sessionResource, stateWithDirectory } = createReadinessSut(); + const firstCancellation = store.add(new CancellationTokenSource()); + const first = service.whenCustomizationsReady(sessionResource, firstCancellation.token); + await timeout(0); const afterFirstQuery = subscription.listenerCount; - await service.whenCustomizationsReady(sessionResource); - await service.whenCustomizationsReady(sessionResource); + firstCancellation.cancel(); - assert.deepStrictEqual({ - afterFirstQuery, - afterThreeQueries: subscription.listenerCount, - stillUnresolved: subscription.value === undefined, - }, { + const secondCancellation = store.add(new CancellationTokenSource()); + const second = service.whenCustomizationsReady(sessionResource, secondCancellation.token); + await timeout(0); + const afterSecondQuery = subscription.listenerCount; + secondCancellation.cancel(); + const cancelledResults = await Promise.all([first, second]); + + subscription.setSnapshot(stateWithDirectory); + const ready = await service.whenCustomizationsReady(sessionResource); + + assert.deepStrictEqual({ afterFirstQuery, afterSecondQuery, cancelledResults, ready }, { afterFirstQuery: 2, - afterThreeQueries: 2, - stillUnresolved: true, + afterSecondQuery: 2, + cancelledResults: [false, false], + ready: true, }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationsTelemetryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationsTelemetryService.test.ts new file mode 100644 index 000000000000..edd484a09784 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationsTelemetryService.test.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { CustomizationType, McpServerStatus, type ChildCustomization, type Customization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IAgentHostActiveClientService } from '../../../browser/agentSessions/agentHost/agentHostActiveClientService.js'; +import { IAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; +import { IChatRequestAcceptedEvent, IChatService } from '../../../common/chatService/chatService.js'; +import { IChatChangeEvent, IChatModel, IChatRequestModel } from '../../../common/model/chatModel.js'; +import { CustomizationsTelemetryContribution, CustomizationsTelemetryService, ICustomizationsTelemetryService } from '../../../browser/aiCustomization/customizationsTelemetryService.js'; + +suite('CustomizationsTelemetryService', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reports source counts once from the completed session snapshot', () => { + const customizationService = new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeCustomizations = Event.None; + override getClientWorkingDirectoryUris() { + return []; + } + override getWorkingDirectories() { + return ['file:///workspace']; + } + override getCustomizations(): readonly Customization[] { + return [ + plugin('file:///plugin', [ + child(CustomizationType.Prompt, 'file:///plugin/commands/prompt.md'), + ]), + plugin('vscode-synced-customization:/bundle', [ + child(CustomizationType.Agent, 'file:///host/agents/extension.agent.md'), + child(CustomizationType.Skill, 'file:///host/skills/builtin/SKILL.md'), + ]), + directory([ + child(CustomizationType.Rule, 'file:///workspace/.github/instructions/workspace.instructions.md'), + child(CustomizationType.Hook, 'file:///user/hooks/hooks.json'), + ]), + ]; + } + override getMcpServers() { + return [ + mcpServer('workspace'), + mcpServer('plugin'), + mcpServer('builtin'), + mcpServer('managed'), + mcpServer(undefined), + ]; + } + }(); + const activeClientService = new class extends mock() { + override getOrigin(resource: URI) { + if (resource.path.endsWith('/agents/extension.agent.md')) { + return { uri: URI.file('/extension.agent.md'), source: AICustomizationSources.extension }; + } + if (resource.path.endsWith('/skills/builtin/SKILL.md')) { + return { uri: URI.file('/builtin/SKILL.md'), source: AICustomizationSources.builtin }; + } + return undefined; + } + }(); + const events: { eventName: string; data: unknown }[] = []; + const telemetryService = new class extends NullTelemetryServiceShape { + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName) { + events.push({ eventName, data }); + } + } + }(); + const errors: unknown[][] = []; + const service = new CustomizationsTelemetryService( + customizationService, + activeClientService, + telemetryService as ITelemetryService, + new class extends mock() { + override error(...args: unknown[]): void { + errors.push(args); + } + }(), + ); + const sessionResource = URI.from({ scheme: 'agent-host-copilotcli', path: '/session' }); + + service.reportNewSession(sessionResource); + service.reportNewSession(sessionResource); + assert.deepStrictEqual({ + errors, + events, + }, { + errors: [], + events: [ + event('agent', { extensionCount: 1 }), + event('instructions', { workspaceCount: 1 }), + event('prompt', { pluginCount: 1 }), + event('skill', { builtinCount: 1 }), + event('hook', { userCount: 1 }), + event('mcpServer', { userCount: 1, workspaceCount: 1, pluginCount: 1, builtinCount: 2 }), + event('plugin', { pluginCount: 1 }), + ], + }); + }); + + test('reports after the first accepted request for a new session completes', () => { + const requests = new Emitter(); + const reported: string[] = []; + const firstRequest = new class extends mock() { override readonly id = 'first'; }(); + const secondRequest = new class extends mock() { override readonly id = 'second'; }(); + const firstModelChanges = new Emitter(); + const secondModelChanges = new Emitter(); + const models = new Map([ + ['agent-host-copilotcli:/one', new class extends mock() { + override readonly onDidChange = firstModelChanges.event; + override readonly lastRequest = firstRequest; + }()], + ['agent-host-claude:/two', new class extends mock() { + override readonly onDidChange = secondModelChanges.event; + override readonly lastRequest = secondRequest; + }()], + ]); + const contribution = new CustomizationsTelemetryContribution(new class extends mock() { + override readonly onDidAcceptRequest = requests.event; + override getSession(resource: URI): IChatModel | undefined { + return models.get(resource.toString()); + } + }(), new class extends mock() { + override reportNewSession(resource: URI): void { + reported.push(resource.toString()); + } + }()); + + requests.fire({ chatSessionResource: URI.parse('agent-host-copilotcli:/one'), isNewSession: true }); + requests.fire({ chatSessionResource: URI.parse('agent-host-copilotcli:/one'), isNewSession: false }); + requests.fire({ chatSessionResource: URI.parse('agent-host-claude:/two'), isNewSession: true }); + assert.deepStrictEqual(reported, []); + + firstModelChanges.fire({ kind: 'completedRequest', request: firstRequest }); + secondModelChanges.fire({ kind: 'completedRequest', request: secondRequest }); + contribution.dispose(); + requests.dispose(); + firstModelChanges.dispose(); + secondModelChanges.dispose(); + + assert.deepStrictEqual(reported, [ + 'agent-host-copilotcli:/one', + 'agent-host-claude:/two', + ]); + }); +}); + +function child(type: ChildCustomization['type'], uri: string): ChildCustomization { + return { + id: uri, + uri, + type, + name: uri, + } as ChildCustomization; +} + +function plugin(uri: string, children: ChildCustomization[]): Customization { + return { + id: uri, + uri, + type: CustomizationType.Plugin, + name: uri, + children, + }; +} + +function directory(children: ChildCustomization[]): Customization { + return { + id: 'directory', + uri: 'file:///user', + type: CustomizationType.Directory, + name: 'directory', + enabled: true, + contents: CustomizationType.Rule, + writable: true, + children, + }; +} + +function mcpServer(source: 'user' | 'workspace' | 'plugin' | 'builtin' | 'managed' | undefined) { + return { + id: `server-${source}`, + name: `server-${source}`, + source, + enabled: true, + status: McpServerStatus.Ready, + state: { kind: McpServerStatus.Ready } as const, + start: async () => { }, + stop: async () => { }, + setEnabled: () => { }, + }; +} + +function event(customizationType: string, counts: Partial>) { + return { + eventName: 'agents/customizationsDefined', + data: { + customizationType, + userCount: 0, + workspaceCount: 0, + extensionCount: 0, + pluginCount: 0, + builtinCount: 0, + ...counts, + }, + }; +} diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/remoteAgentHostCustomizationHarness.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/remoteAgentHostCustomizationHarness.test.ts index 0bc8acd08c4a..c4957fe75a34 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/remoteAgentHostCustomizationHarness.test.ts @@ -183,7 +183,7 @@ function createTestCustomAgentsService(connection: MockAgentConnection, rootCust return [...rootCustomizations, ...(sessionState.customizations ?? [])]; }, getFolderPickerDecision: () => undefined, - whenCustomizationsReady: () => Promise.resolve(), + whenCustomizationsReady: () => Promise.resolve(true), getWorkingDirectory(sessionResource: URI): string | undefined { return undefined; }, From 4738842112843b232f7775ca81bb21eda53bbd4b Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Sun, 4 Oct 2026 23:39:47 +0200 Subject: [PATCH 17/49] sessions: Track accepted Dev Container sample selections (#339588) Record a bounded sampleId after a user selection succeeds across submenu, filtered, and recent workspace entries. Preserve generic picker telemetry privacy filtering and cover non-sample, programmatic, unavailable, failed, declined, and superseded selections. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/sessionWorkspacePicker.ts | 16 ++- .../browser/sessionWorkspacePicker.test.ts | 136 +++++++++++++++++- 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 2ad522ec7443..ecd7d1810cbb 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -24,7 +24,7 @@ import { ITabDescriptor, TabbedActionListWidget } from '../../../../platform/act import { IMenuService, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { TUNNEL_ADDRESS_PREFIX } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; -import { devContainerSamples, devContainerSampleUri, findDevContainerSample, getDevContainerSampleUrl } from '../../../../platform/agentHost/common/devContainerSamples.js'; +import { DevContainerSample, devContainerSamples, devContainerSampleUri, findDevContainerSample, getDevContainerSampleUrl } from '../../../../platform/agentHost/common/devContainerSamples.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpression, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -61,6 +61,16 @@ import { registerPickerKeybindingPresentation } from './newChatPickerKeybinding. export type { IResolvedFolderWorkspace } from './sessionWorkspaceFallback.js'; +type DevContainerSampleSelectedEvent = { + sampleId: DevContainerSample['id']; +}; + +type DevContainerSampleSelectedClassification = { + owner: 'chrmarti'; + comment: 'Records accepted user selections of Dev Container samples in the Agents workspace picker, not container provisioning or session creation.'; + sampleId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The selected built-in sample catalog identifier: go, dotnet, node, php, python, or rust. Does not contain a workspace path or repository URL.' }; +}; + const FILTER_THRESHOLD = 10; /** @@ -1046,6 +1056,10 @@ export class WorkspacePicker extends Disposable { this._selectedDevContainerFolderUri = item.preferDevContainer ? item.folderUri : undefined; this._onDidSelectWorkspaceMode.fire({ folderUri: item.folderUri, preferDevContainer: item.preferDevContainer === true }); this._selectFolder(item.folderUri, true, item.providerId); + const sample = findDevContainerSample(item.folderUri); + if (sample) { + this.telemetryService.publicLog2('devContainerSampleSelected', { sampleId: sample.id }); + } return true; } return false; diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index a1cf01f01bab..c3a9c4ea024d 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -342,7 +342,7 @@ function createTestPicker( fileDialogService: Partial = {}, workspacesService: IWorkspacesService = { getRecentlyOpened: async () => ({ workspaces: [], files: [] }), onDidChangeRecentlyOpened: Event.None } as unknown as IWorkspacesService, recentWorkspacesService?: ISessionsRecentWorkspacesService, - options?: IWorkspacePickerOptions & { readonly configuration?: Record }, + options?: IWorkspacePickerOptions & { readonly configuration?: Record; readonly telemetryService?: ITelemetryService }, fileService: IFileService = upcastPartial({ onDidFilesChange: Event.None, onDidChangeFileSystemProviderRegistrations: Event.None, @@ -383,7 +383,7 @@ function createTestPicker( instantiationService.stub(IWorkspacesService, workspacesService); instantiationService.stub(ILogService, disposables.add(new NullLogService())); instantiationService.stub(ISessionsRecentWorkspacesService, recentWorkspacesService ?? disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService))); - instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(ITelemetryService, options?.telemetryService ?? NullTelemetryService); instantiationService.stub(IHoverService, NullHoverService); if (agentHostFilterService) { instantiationService.stub(IAgentHostFilterService, agentHostFilterService); @@ -463,6 +463,138 @@ function assertSelectedProvider(picker: WorkspacePicker, expectedProviderId: str // ---- Tests ------------------------------------------------------------------ +suite('WorkspacePicker - Dev Container sample telemetry', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function setupPicker(options: IWorkspacePickerOptions = {}) { + const events: { name: string; data: object | undefined }[] = []; + const telemetryService = new class extends mock() { + override publicLog2(name: string, data?: object): void { + events.push({ name, data }); + } + }(); + const providersService = store.add(new MockSessionsProvidersService()); + const provider = { + ...createMockProvider('local-agent-host', { group: SESSION_WORKSPACE_GROUP_LOCAL }), + supportsLocalWorkspaces: true, + }; + providersService.setProviders([provider]); + const picker = createTestPicker(store, providersService, undefined, undefined, DispatchingWorkspacePicker, undefined, undefined, undefined, { + restoreFromSessions: false, + ...options, + telemetryService, + configuration: { + [DevContainerSamplesEnabledSettingId]: true, + [DevContainerAgentHostEnabledSettingId]: true, + }, + }); + assert.ok(picker instanceof DispatchingWorkspacePicker); + return { picker, provider, providersService, events, sampleEvents: () => events.filter(event => event.name === 'devContainerSampleSelected') }; + } + + for (const surface of ['submenu', 'filtered results', 'recent workspace'] as const) { + test(`reports only the catalog ID once per accepted selection from ${surface}`, async () => { + const { picker, provider, events, sampleEvents } = setupPicker(); + for (const sample of devContainerSamples) { + const uri = devContainerSampleUri(sample); + const eventCount = events.length; + let item: IWorkspacePickerItem | undefined; + if (surface === 'recent workspace') { + picker.setSelectedWorkspace(uri, { fireEvent: false, providerId: provider.id }); + item = picker.getItems().find(entry => extUri.isEqual(entry.item?.folderUri, uri))?.item; + } else { + const parent = picker.getItems().find(entry => entry.label === 'Dev Container Sample'); + if (surface === 'filtered results') { + item = parent?.filterItems?.find(entry => entry.label === sample.name)?.item; + } else { + const submenu = parent?.submenuActions?.[0]; + assert.ok(submenu instanceof SubmenuAction); + const action = submenu.actions.find(action => action.label === sample.name); + assert.ok(action); + await action.run(); + item = parent?.item; + } + } + assert.strictEqual(events.length, eventCount, 'Rendering, restoring, and invoking a submenu callback must not log an accepted selection'); + assert.ok(item); + assert.strictEqual(await picker.dispatchItem(item), true); + } + assert.deepStrictEqual({ + samples: sampleEvents(), + pickerEvents: events.filter(event => event.name === 'newChatPickerClosed'), + }, { + samples: devContainerSamples.map(sample => ({ name: 'devContainerSampleSelected', data: { sampleId: sample.id } })), + pickerEvents: devContainerSamples.map(() => ({ + name: 'newChatPickerClosed', + data: { + id: 'NewChatWorkspacePicker', + name: 'NewChatWorkspacePicker', + selectionChanged: surface !== 'recent workspace', + optionIdBefore: undefined, + optionIdAfter: undefined, + optionLabelBefore: undefined, + optionLabelAfter: undefined, + }, + })), + }); + }); + } + + test('does not report ordinary folders, malformed sample URIs, or programmatic selection', async () => { + const { picker, provider, sampleEvents } = setupPicker(); + const sampleUri = devContainerSampleUri(devContainerSamples[0]); + picker.setSelectedWorkspace(sampleUri, { providerId: provider.id }); + for (const uri of [ + URI.file('/agent-host/private-project'), + URI.file('/agent-host/vscode-remote-try-go'), + sampleUri.with({ authority: 'private-host' }), + sampleUri.with({ query: 'private-query' }), + sampleUri.with({ fragment: 'private-fragment' }), + sampleUri.with({ path: '/private-project' }), + ]) { + await picker.dispatchFolder(uri, provider.id); + } + assert.deepStrictEqual(sampleEvents(), []); + }); + + for (const decision of ['declined', 'failed', 'superseded'] as const) { + test(`does not report a ${decision} sample selection`, async () => { + const acceptance = new DeferredPromise(); + const pending = new DeferredPromise(); + const { picker, provider, sampleEvents } = setupPicker({ + canSelectWorkspace: () => { + pending.complete(); + return acceptance.p; + }, + }); + const selection = picker.dispatchFolder(devContainerSampleUri(devContainerSamples[0]), provider.id); + const rejected = decision === 'failed' ? assert.rejects(selection, /Selection failed/) : undefined; + await pending.p; + if (decision === 'superseded') { + picker.clearSelection(); + } + if (decision === 'failed') { + acceptance.error(new Error('Selection failed')); + await rejected; + } else { + acceptance.complete(decision === 'superseded'); + assert.strictEqual(await selection, false); + } + assert.deepStrictEqual(sampleEvents(), []); + }); + } + + test('does not report a sample selection from an unavailable provider', async () => { + const { picker, providersService, sampleEvents } = setupPicker(); + const provider = createMockProvider('agenthost-unavailable', { + connectionStatus: observableValue('status', RemoteAgentHostConnectionStatus.disconnected), + }); + providersService.setProviders([provider]); + await picker.dispatchFolder(devContainerSampleUri(devContainerSamples[0]), provider.id); + assert.deepStrictEqual(sampleEvents(), []); + }); +}); + suite('WorkspacePicker - Connection Status', () => { const disposables = new DisposableStore(); From 15dcffd43f50142699c241198bfee374b2253bb7 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sun, 4 Oct 2026 14:42:54 -0700 Subject: [PATCH 18/49] sessions: Fix empty cloud sandbox resume views (#339605) * sessions: Fix empty cloud sandbox resume views Join an explicit sandbox wake during session activation instead of resolving offline history while the connection is still pending. Replay an unambiguous recorded chat when no default was announced, while preserving explicit defaults and respecting peer-chat catalogue metadata. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Respect removed chats during cloud history replay Distinguish missing chat catalogue evidence from an explicitly emptied catalogue before inferring a default chat. Cover removed user, tool, side, and fork chats, including removals whose additions were not recorded. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/taskEventReplay.ts | 34 +++-- .../test/common/taskEventReplay.test.ts | 136 ++++++++++++++++++ .../cloudSandboxAgentHostContribution.test.ts | 70 +++++++++ .../cloudSandboxSessionContribution.ts | 4 +- 4 files changed, 224 insertions(+), 20 deletions(-) diff --git a/src/vs/platform/agentHost/common/taskEventReplay.ts b/src/vs/platform/agentHost/common/taskEventReplay.ts index 14d04851e11b..bd68bd05dff5 100644 --- a/src/vs/platform/agentHost/common/taskEventReplay.ts +++ b/src/vs/platform/agentHost/common/taskEventReplay.ts @@ -16,9 +16,8 @@ import { ChunkEnvelope, Reassembler } from './webPubSub/chunking.js'; import { ActionEnvelope, ActionType, StateAction } from './state/protocol/common/actions.js'; -import { SessionDefaultChatChangedAction } from './state/protocol/channels-session/actions.js'; import { chatReducer } from './state/protocol/channels-chat/reducer.js'; -import { ChatState } from './state/protocol/channels-chat/state.js'; +import { ChatOriginKind, ChatState } from './state/protocol/channels-chat/state.js'; import { sessionReducer } from './state/protocol/channels-session/reducer.js'; import { SessionLifecycle, SessionState, SessionStatus } from './state/protocol/channels-session/state.js'; import { ChatAction, SessionAction } from './state/sessionActions.js'; @@ -48,11 +47,8 @@ export interface IReplayedSession { /** Folded chat-channel state, keyed by chat channel URI. */ readonly chats: ReadonlyMap; /** - * Channel of the session's default chat, as the recorded history named it. - * - * Resolved from the history rather than derived locally: the host writes whatever channel - * convention it uses (today `/chat`), which need not match the URI a client would - * build for the same chat. + * The host-announced default chat, or a sole recorded chat consistent with all recorded catalogue evidence. + * History without either retains the legacy `/chat` fallback. */ readonly defaultChat: string; /** Timestamp of the last persisted event, ISO 8601. */ @@ -227,9 +223,7 @@ function decodeEvents(events: readonly unknown[]): Map/chat`, not the `ahp-chat://` URI a client builds), so matching on a scheme would - * silently drop every chat action. The live subscriptions route the same way. + * Routed by action type, not channel scheme, so host-defined chat URIs are preserved. * * A session may own several peer chats, so each chat channel found in the history gets its own fold * — discovered from the envelopes rather than assumed, which keeps forked and peer chats intact. @@ -237,19 +231,18 @@ function decodeEvents(events: readonly unknown[]): Map(); - // The host announces its default chat via `session/defaultChatChanged`; until then the - // deterministic `/chat` is the convention it writes. - let defaultChat = `${session}/chat`; + let hasChatCatalogueEvidence = false; for (const envelope of entry.envelopes) { const channel = envelope.channel; const action: StateAction = envelope.action; if (action.type.startsWith('session/') && channel === session) { + hasChatCatalogueEvidence ||= action.type === ActionType.SessionChatAdded + || action.type === ActionType.SessionChatRemoved + || action.type === ActionType.SessionChatUpdated + || action.type === ActionType.SessionChatsReordered; state = sessionReducer(state, action as SessionAction); - if (action.type === ActionType.SessionDefaultChatChanged) { - defaultChat = (action as SessionDefaultChatChangedAction).defaultChat || `${session}/chat`; - } continue; } if (action.type.startsWith('chat/')) { @@ -260,8 +253,13 @@ function foldSession(session: string, entry: ISessionReplayState): IReplayedSess // are intentionally skipped. } - // A session whose history never announced its chats still owns a default chat, so surface an - // empty one rather than a session that appears to have no conversation at all. + const [recordedChat] = chats.keys(); + const unambiguousChat = chats.size === 1 + && (!hasChatCatalogueEvidence || state.chats.length === 1) + && state.chats.every(chat => + chat.resource === recordedChat && (!chat.origin || chat.origin.kind === ChatOriginKind.User)) + ? recordedChat : undefined; + const defaultChat = state.defaultChat || unambiguousChat || `${session}/chat`; if (!chats.has(defaultChat)) { chats.set(defaultChat, seedChatState(defaultChat, entry.modifiedAt)); } diff --git a/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts b/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts index 52b32a5c19e3..d29ee9b95b46 100644 --- a/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts +++ b/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts @@ -239,6 +239,142 @@ suite('Task event replay', () => { { defaultChat: announced, turns: ['t1'] }); }); + test('uses the only recorded chat when the default was not announced', () => { + const chat = 'ahp-chat:/90b9344c160a544093d7a3ebf4089e3f'; + const history = replayTaskAhpEvents(completedTurn(SESSION_A, 0, chat, 't1', 'hello')); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + chats: [...(session?.chats.keys() ?? [])], + turns: session?.chats.get(session.defaultChat)?.turns.map(turn => turn.id), + }, { defaultChat: chat, chats: [chat], turns: ['t1'] }); + }); + + for (const origin of [ + { kind: 'tool', chat: 'ahp-chat:/main', toolCallId: 'tool-1' }, + { kind: 'sideChat', chat: 'ahp-chat:/main', turnId: 't0' }, + { kind: 'fork', chat: 'ahp-chat:/main', turnId: 't0' }, + ]) { + test(`does not infer the default from a sole recorded ${origin.kind} chat`, () => { + const peer = 'ahp-chat:/peer'; + const history = replayTaskAhpEvents([ + event(SESSION_A, 0, SESSION_A, { + type: 'session/chatAdded', + summary: { resource: peer, title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z', origin }, + }), + ...completedTurn(SESSION_A, 1, peer, 't1', 'peer conversation'), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns, + peerTurns: session?.chats.get(peer)?.turns.map(turn => turn.id), + }, { defaultChat: defaultChat(SESSION_A), turns: [], peerTurns: ['t1'] }); + }); + } + + test('does not infer a default when the catalogue advertises another chat', () => { + const history = replayTaskAhpEvents([ + event(SESSION_A, 0, SESSION_A, { + type: 'session/chatAdded', + summary: { resource: 'ahp-chat:/main', title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z', origin: { kind: 'user' } }, + }), + ...completedTurn(SESSION_A, 1, 'ahp-chat:/peer', 't1', 'peer conversation'), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns, + }, { defaultChat: defaultChat(SESSION_A), turns: [] }); + }); + + for (const kind of ['user', 'tool', 'sideChat', 'fork']) { + test(`does not infer the default from a removed ${kind} chat`, () => { + const chat = 'ahp-chat:/removed'; + const history = replayTaskAhpEvents([ + event(SESSION_A, 0, SESSION_A, { + type: 'session/chatAdded', + summary: { + resource: chat, title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z', + origin: { kind, chat: 'ahp-chat:/main', turnId: 't0', toolCallId: 'tool-1' }, + }, + }), + ...completedTurn(SESSION_A, 1, chat, 't1', 'removed conversation'), + event(SESSION_A, 3, SESSION_A, { type: 'session/chatRemoved', chat }), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + catalogue: session?.state.chats, + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns, + }, { catalogue: [], defaultChat: defaultChat(SESSION_A), turns: [] }); + }); + } + + test('honours a chat removal even when its addition was not recorded', () => { + const chat = 'ahp-chat:/removed'; + const history = replayTaskAhpEvents([ + ...completedTurn(SESSION_A, 0, chat, 't1', 'removed conversation'), + event(SESSION_A, 2, SESSION_A, { type: 'session/chatRemoved', chat }), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns, + }, { defaultChat: defaultChat(SESSION_A), turns: [] }); + }); + + test('uses the sole recorded user chat advertised in the catalogue', () => { + const chat = 'ahp-chat:/main'; + const history = replayTaskAhpEvents([ + event(SESSION_A, 0, SESSION_A, { + type: 'session/chatAdded', + summary: { resource: chat, title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z', origin: { kind: 'user' } }, + }), + ...completedTurn(SESSION_A, 1, chat, 't1', 'main conversation'), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns.map(turn => turn.id), + }, { defaultChat: chat, turns: ['t1'] }); + }); + + test('does not replace an announced empty default with a peer chat', () => { + const announced = 'ahp-chat:/main'; + const peer = 'ahp-chat:/peer'; + const history = replayTaskAhpEvents([ + event(SESSION_A, 0, SESSION_A, { type: 'session/defaultChatChanged', defaultChat: announced }), + ...completedTurn(SESSION_A, 1, peer, 't1', 'peer conversation'), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns, + peerTurns: session?.chats.get(peer)?.turns.map(turn => turn.id), + }, { defaultChat: announced, turns: [], peerTurns: ['t1'] }); + }); + + test('preserves separate chats when the recorded default is ambiguous', () => { + const history = replayTaskAhpEvents([ + ...completedTurn(SESSION_A, 0, 'ahp-chat:/first', 't1', 'first conversation'), + ...completedTurn(SESSION_A, 2, 'ahp-chat:/second', 't2', 'second conversation'), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: [...(session?.chats.values() ?? [])].map(chat => chat.turns.map(turn => turn.id)), + }, { defaultChat: defaultChat(SESSION_A), turns: [['t1'], ['t2'], []] }); + }); + test('surfaces an empty default chat for a session with no chat history', () => { const history = replayTaskAhpEvents([event(SESSION_A, 0, SESSION_A, titleChanged('no chat yet'))]); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts index 846d195c6d4c..465366cab1b2 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts @@ -786,6 +786,76 @@ suite('CloudSandboxAgentHostContribution', () => { }); }); + for (const fails of [false, true]) { + test(`waits for an explicit sandbox wake before ${fails ? 'falling back to history' : 'completing activation'}`, async () => { + const harness = await createContribution(store, [discoveredSession()]); + const wake = new DeferredPromise(); + harness.onConnect = () => wake.p; + const connection = harness.contribution.connect({ environmentId: 'env-1', sessionId: 'sess-1', name: 'Sandbox' }); + const connectionResult = connection.then(() => 'connected', () => 'failed'); + let opened: boolean | undefined; + const activation = harness.activate('env-1').then(result => { opened = result; }); + await timeout(0); + + const duringWake = { opened, historyRequests: [...harness.historyRequests], servedFromHistory: harness.readOnlySessionTypes.length }; + if (fails) { + await wake.error(new Error('Expected wake failure')); + } else { + await wake.complete(); + } + await activation; + + assert.deepStrictEqual({ + duringWake, + connection: await connectionResult, + connectedTo: harness.connectedTo, + historyRequests: harness.historyRequests, + servedFromHistory: harness.readOnlySessionTypes.length, + }, { + duringWake: { opened: undefined, historyRequests: [], servedFromHistory: 0 }, + connection: fails ? 'failed' : 'connected', + connectedTo: ['env-1'], + historyRequests: fails ? ['task-1'] : [], + servedFromHistory: fails ? 1 : 0, + }); + }); + } + + test('joins a sandbox wake that starts during the environment lookup', async () => { + const environment = new DeferredPromise(); + const requested = new DeferredPromise(); + const wake = new DeferredPromise(); + const harness = await createContribution(store, [discoveredSession()], { + getEnvironment: () => { + requested.complete(); + return environment.p; + }, + }); + let opened: boolean | undefined; + const activation = harness.activate('env-1').then(result => { opened = result; }); + await requested.p; + harness.onConnect = () => wake.p; + const connection = harness.contribution.connect({ environmentId: 'env-1', sessionId: 'sess-1', name: 'Sandbox' }); + await environment.complete({ id: 'env-1', status: 'offline' }); + await timeout(0); + + const duringWake = { opened, historyRequests: [...harness.historyRequests], servedFromHistory: harness.readOnlySessionTypes.length }; + await wake.complete(); + await Promise.all([connection, activation]); + + assert.deepStrictEqual({ + duringWake, + connectedTo: harness.connectedTo, + historyRequests: harness.historyRequests, + servedFromHistory: harness.readOnlySessionTypes.length, + }, { + duringWake: { opened: undefined, historyRequests: [], servedFromHistory: 0 }, + connectedTo: ['env-1'], + historyRequests: [], + servedFromHistory: 0, + }); + }); + test('does not wake an environment whose state could not be read', async () => { const harness = await createContribution(store, [discoveredSession()], { getEnvironment: async () => { throw new Error('Expected environment lookup failure'); }, diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts index 69ec9fd84d5c..bd5e284978de 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts @@ -579,11 +579,11 @@ export abstract class CloudSandboxSessionContribution Date: Sun, 4 Oct 2026 14:44:25 -0700 Subject: [PATCH 19/49] agentHost: renew sandbox credentials without prompting for sign-in (#339618) Handle expired sandbox credentials through connection-owned renewal before applying generic GitHub session retry limits. Preserve duplicate-challenge coalescing, fail-closed renewal, and connection replacement guards, with regression coverage. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../remoteAgentHost.contribution.test.ts | 187 ++++++++++++++++++ .../agentSessions/agentHost/agentHostAuth.ts | 10 +- .../cloudSandboxConnectionCustomization.ts | 26 +-- .../remoteAgentHostChatContribution.ts | 11 ++ .../remoteAgentHostConnectionCustomization.ts | 4 + ...loudSandboxConnectionCustomization.test.ts | 23 ++- 6 files changed, 244 insertions(+), 17 deletions(-) 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 851873e25ab1..094552b53408 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts @@ -35,6 +35,7 @@ import { IRemoteAgentHostAuthenticationService, RemoteAgentHostAuthenticationSer import { RemoteAgentHostLogForwarder } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostLogForwarder.js'; import { CloudSandboxApiService } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.js'; import { CloudSandboxAgentHostService } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxAgentHostService.js'; +import { createCloudSandboxConnectionCustomization } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.js'; import { SSHAgentHostContribution } from '../../browser/sshAgentHost.contribution.js'; import { WebSocketAgentHostContribution } from '../../browser/webSocketAgentHost.contribution.js'; import '../../browser/remoteAgentHost.contribution.js'; @@ -180,6 +181,192 @@ suite('RemoteAgentHost connection authentication readiness', () => { suite('RemoteAgentHost auth notifications', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('renews repeated sandbox expiry without prompting for an unchanged GitHub session', async () => { + const h = createAuthenticationHarness(store); + const forwarded: string[] = []; + const connection = h.connect(async request => { + forwarded.push(request.token); + return { authenticated: true }; + }); + let prompts = 0; + h.contribution._instantiationService.stub(ICommandService, { + executeCommand: async () => { + prompts++; + return { success: undefined } as R; + }, + }); + let renewals = 0; + const sandboxService = new class extends mock() { + override getSealedGitHubToken(): string { + return 'copilot-sealed.v1.key.cached'; + } + override async refreshSealedGitHubToken(): Promise { + return `copilot-sealed.v1.key.renewed-${++renewals}`; + } + }(); + h.contribution._connectionCustomizations = { + get: address => createCloudSandboxConnectionCustomization(address, sandboxService), + }; + const notification: INotification = { + type: NotificationType.AuthRequired, + channel: 'ahp-root://', + resource: { ...h.resource, resource: 'https://api.github.com' }, + reason: AuthRequiredReason.Expired, + }; + + await h.contribution._authenticateWithConnection(h.address, connection, [{ ...h.agents[0], protectedResources: [notification.resource] }]); + for (let i = 0; i < 3; i++) { + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await timeout(0); + } + + assert.deepStrictEqual({ forwarded, prompts, renewals }, { + forwarded: ['copilot-sealed.v1.key.cached', 'copilot-sealed.v1.key.renewed-1', 'copilot-sealed.v1.key.renewed-2', 'copilot-sealed.v1.key.renewed-3'], + prompts: 0, + renewals: 3, + }); + }); + + test('shares sandbox renewal through authentication completion without resolving GitHub sessions', async () => { + const h = createAuthenticationHarness(store); + let sessionLookups = 0; + h.contribution._instantiationService.stub(IAuthenticationService, { + getOrActivateProviderIdForServer: async () => { + sessionLookups++; + throw new Error('Sandbox renewal must not resolve a user session'); + }, + }); + const renewed = new DeferredPromise(); + const authenticated = new DeferredPromise<{ authenticated: true }>(); + const forwarded: string[] = []; + const connection = h.connect(async request => { + forwarded.push(request.token); + return authenticated.p; + }); + let renewals = 0; + const sandboxService = new class extends mock() { + override refreshSealedGitHubToken(): Promise { + renewals++; + return renewed.p; + } + }(); + h.contribution._connectionCustomizations = { + get: address => createCloudSandboxConnectionCustomization(address, sandboxService), + }; + const notification: INotification = { + type: NotificationType.AuthRequired, + channel: 'ahp-root://', + resource: { ...h.resource, resource: 'https://api.github.com' }, + reason: AuthRequiredReason.Expired, + }; + + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await renewed.complete('copilot-sealed.v1.key.renewed'); + await timeout(0); + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await authenticated.complete({ authenticated: true }); + await timeout(0); + + assert.deepStrictEqual({ forwarded, renewals, sessionLookups }, { + forwarded: ['copilot-sealed.v1.key.renewed'], + renewals: 1, + sessionLookups: 0, + }); + }); + + test('logs failed sandbox renewal without forwarding stale credentials or prompting and allows a later retry', async () => { + const h = createAuthenticationHarness(store); + const forwarded: string[] = []; + const connection = h.connect(async request => { + forwarded.push(request.token); + return { authenticated: true }; + }); + const errors: string[] = []; + h.contribution._logService = new class extends NullLogService { + override error(message: string): void { + errors.push(message); + } + }(); + let prompts = 0; + h.contribution._instantiationService.stub(ICommandService, { + executeCommand: async () => { + prompts++; + return { success: undefined } as R; + }, + }); + let renewals = 0; + const sandboxService = new class extends mock() { + override getSealedGitHubToken(): string { + throw new Error('Must not fall back to cached credentials'); + } + override async refreshSealedGitHubToken(): Promise { + if (++renewals === 1) { + throw new Error('renewal unavailable'); + } + return 'copilot-sealed.v1.key.renewed'; + } + }(); + h.contribution._connectionCustomizations = { + get: address => createCloudSandboxConnectionCustomization(address, sandboxService), + }; + const notification: INotification = { + type: NotificationType.AuthRequired, + channel: 'ahp-root://', + resource: { ...h.resource, resource: 'https://api.github.com' }, + reason: AuthRequiredReason.Expired, + }; + + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await timeout(0); + const forwardedAfterFailure = [...forwarded]; + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await timeout(0); + + assert.deepStrictEqual({ forwardedAfterFailure, forwarded, prompts, renewals, errors }, { + forwardedAfterFailure: [], + forwarded: ['copilot-sealed.v1.key.renewed'], + prompts: 0, + renewals: 2, + errors: ['[RemoteAgentHost] Failed to authenticate notified resource https://api.github.com'], + }); + }); + + test('does not forward renewed sandbox credentials after the connection is replaced', async () => { + const h = createAuthenticationHarness(store); + const forwarded: string[] = []; + const connection = h.connect(async request => { + forwarded.push(request.token); + return { authenticated: true }; + }); + const renewed = new DeferredPromise(); + const sandboxService = new class extends mock() { + override refreshSealedGitHubToken(): Promise { + return renewed.p; + } + }(); + h.contribution._connectionCustomizations = { + get: address => ({ ...createCloudSandboxConnectionCustomization(address, sandboxService), createSessionPreparation: undefined }), + }; + const notification: INotification = { + type: NotificationType.AuthRequired, + channel: 'ahp-root://', + resource: { ...h.resource, resource: 'https://api.github.com' }, + reason: AuthRequiredReason.Expired, + }; + + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await timeout(0); + h.connect(async request => { + forwarded.push(request.token); + return { authenticated: true }; + }); + await renewed.complete('copilot-sealed.v1.key.renewed'); + await timeout(0); + + assert.deepStrictEqual(forwarded, []); + }); + test('resends the current token for an expired notification resource that is not advertised by root agents', async () => { const instantiationService = createAuthenticationInstantiationService(store); instantiationService.stub(IAuthenticationService, { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts index edf88fd0ca34..357df2590618 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts @@ -255,9 +255,15 @@ export class AgentHostAuthenticationRecovery { private async _recover(accessor: ServicesAccessor, key: string, resource: ProtectedResourceMetadata, options: IResolvedAgentHostAuthenticationOptions): Promise { throwIfAuthenticationStale(options); + const logService = accessor.get(ILogService); + if (options.renewAuthentication) { + await options.renewAuthentication(resource); + throwIfAuthenticationStale(options); + logService.info(`${options.logPrefix} Renewed authentication for resource: ${resource.resource}`); + return; + } const authenticationService = accessor.get(IAuthenticationService); const commandService = accessor.get(ICommandService); - const logService = accessor.get(ILogService); const scopes = resource.scopes_supported ?? []; const quarantinePresent = options.authTokenCache?.getRejectedSession(resource.resource, scopes) !== undefined; const resolution = await resolveSessionForProtectedResource(authenticationService, logService, resource, options, null); @@ -473,6 +479,8 @@ export interface IAgentHostAuthenticationOptions { readonly authTokenCache?: AgentHostAuthTokenCache; readonly logPrefix: string; readonly isCurrent?: () => boolean; + /** Renews a connection-owned credential instead of retrying or replacing the user's authentication session. */ + readonly renewAuthentication?: (resource: ProtectedResourceMetadata) => Promise; readonly authenticate: (request: IAgentHostAuthenticateRequest) => Promise; } diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts index 7a743fa14e36..bb371d634c8c 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts @@ -53,25 +53,27 @@ export function createCloudSandboxConnectionCustomization( if (environmentId === undefined) { return undefined; } + const resolveAuthentication = async (resource: string, scopes: readonly string[] | undefined, renew: boolean): Promise => { + if (!isGitHubResource(resource)) { + throw new Error(`Cloud sandbox cannot authenticate the non-GitHub resource '${resource}'.`); + } + const sealed = renew + ? await sandboxService.refreshSealedGitHubToken(environmentId) + : sandboxService.getSealedGitHubToken(environmentId); + if (!sealed || !isCloudSandboxSealedToken(sealed)) { + throw new Error(`No sealed GitHub token is available for cloud sandbox ${address}; refusing to forward a plaintext bearer.`); + } + return { resource, scopes, token: sealed }; + }; return { requiresWorkspaceTrust: false, authenticate: async (request: IAgentHostAuthenticateRequest, reason?: AuthRequiredReason): Promise => { if (reason !== AuthRequiredReason.Expired && isCloudSandboxSealedToken(request.token)) { return request; } - // The sandbox host only accepts the sealed GitHub token for GitHub resources; there is no - // per-resource sealing for other hosts over the sandbox relay today. - if (!isGitHubResource(request.resource)) { - throw new Error(`Cloud sandbox cannot authenticate the non-GitHub resource '${request.resource}'.`); - } - const sealed = reason === AuthRequiredReason.Expired - ? await sandboxService.refreshSealedGitHubToken(environmentId) - : sandboxService.getSealedGitHubToken(environmentId); - if (!sealed || !isCloudSandboxSealedToken(sealed)) { - throw new Error(`No sealed GitHub token is available for cloud sandbox ${address}; refusing to forward a plaintext bearer.`); - } - return { resource: request.resource, scopes: request.scopes, token: sealed }; + return resolveAuthentication(request.resource, request.scopes, reason === AuthRequiredReason.Expired); }, + renewAuthentication: resource => resolveAuthentication(resource.resource, resource.scopes_supported, true), backendSessionScheme: (provider: string): string | undefined => provider === CLOUD_SANDBOX_AGENT_PROVIDER ? CLOUD_SANDBOX_SESSION_SCHEME : undefined, createSessionPreparation: (connection, owner) => { diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts index 7ba4349d4b87..f59a7302cea2 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts @@ -541,9 +541,20 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc if (!connState) { return; } + const renewAuthentication = reason === AuthRequiredReason.Expired + ? this._connectionCustomizations.get(address)?.renewAuthentication + : undefined; this._instantiationService.invokeFunction(accessor => connState.authRecovery.recover(accessor, protectedResource, { authTokenCache: connState.authTokenCache, logPrefix: '[RemoteAgentHost]', + isCurrent: () => this._connections.get(address) === connState, + renewAuthentication: renewAuthentication ? async resource => { + const request = await renewAuthentication(resource); + if (this._connections.get(address) !== connState) { + throw new CancellationError(); + } + await connection.authenticate(request); + } : undefined, authenticate: this._authenticateCallback(address, connection, reason), })) .catch(err => { diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.ts index a6a664b6a925..f55140335bee 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.ts @@ -8,6 +8,7 @@ import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/ import { URI } from '../../../../../base/common/uri.js'; import { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { AuthRequiredReason } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { type ProtectedResourceMetadata } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IAgentHostAuthenticateRequest } from '../agentSessions/agentHost/agentHostAuth.js'; @@ -28,6 +29,9 @@ export interface IRemoteAgentHostConnectionCustomization { */ readonly authenticate?: (request: IAgentHostAuthenticateRequest, reason?: AuthRequiredReason) => Promise; + /** Resolves a fresh connection-owned credential for an expired challenge without requiring a new user sign-in. */ + readonly renewAuthentication?: (resource: ProtectedResourceMetadata) => Promise; + /** * The backend session URI scheme for an agent provider when it differs from the provider itself. * Return `undefined` to keep scheme == provider. diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts index 752ca6d082f5..9b6c2689f146 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts @@ -39,10 +39,22 @@ suite('CloudSandboxConnectionCustomization authentication', () => { } }(); const customization = createCloudSandboxConnectionCustomization(cloudSandboxAddress('env-1'), service); - assert.ok(customization?.authenticate); - return { authenticate: customization.authenticate, requests }; + assert.ok(customization?.authenticate && customization.renewAuthentication); + return { authenticate: customization.authenticate, renewAuthentication: customization.renewAuthentication, requests }; } + test('renews a protected resource without a user authentication token', async () => { + const fixture = createFixture(); + const result = await fixture.renewAuthentication({ + resource: 'https://api.github.com', + scopes_supported: ['repo', 'read:user'], + }); + assert.deepStrictEqual({ result, requests: fixture.requests }, { + result: { resource: 'https://api.github.com', scopes: ['repo', 'read:user'], token: 'copilot-sealed.v1.key.fresh' }, + requests: ['refresh:env-1'], + }); + }); + for (const token of ['plaintext', 'copilot-sealed.v1.key.challenged']) { test(`renews an expired challenge instead of forwarding ${token}`, async () => { const fixture = createFixture(); @@ -76,14 +88,16 @@ suite('CloudSandboxConnectionCustomization authentication', () => { const failure = new Error('refresh unavailable'); const fixture = createFixture(async () => { throw failure; }); await assert.rejects(fixture.authenticate({ resource: 'https://api.github.com', token: 'plaintext' }, AuthRequiredReason.Expired), failure); - assert.deepStrictEqual(fixture.requests, ['refresh:env-1']); + await assert.rejects(fixture.renewAuthentication({ resource: 'https://api.github.com' }), failure); + assert.deepStrictEqual(fixture.requests, ['refresh:env-1', 'refresh:env-1']); }); for (const token of [undefined, 'plaintext']) { test(`rejects an unusable renewed credential: ${token}`, async () => { const fixture = createFixture(async () => token); await assert.rejects(fixture.authenticate({ resource: 'https://api.github.com', token: 'plaintext' }, AuthRequiredReason.Expired), /No sealed GitHub token/); - assert.deepStrictEqual(fixture.requests, ['refresh:env-1']); + await assert.rejects(fixture.renewAuthentication({ resource: 'https://api.github.com' }), /No sealed GitHub token/); + assert.deepStrictEqual(fixture.requests, ['refresh:env-1', 'refresh:env-1']); }); } @@ -91,6 +105,7 @@ suite('CloudSandboxConnectionCustomization authentication', () => { const fixture = createFixture(); for (const resource of ['https://example.com', 'https://github.com.example.com', 'not a URL']) { await assert.rejects(fixture.authenticate({ resource, token: 'copilot-sealed.v1.key.challenged' }, AuthRequiredReason.Expired), /non-GitHub resource/); + await assert.rejects(fixture.renewAuthentication({ resource }), /non-GitHub resource/); } assert.deepStrictEqual(fixture.requests, []); }); From f11110f9ea1793b7ee2689615193202421649734 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sun, 4 Oct 2026 14:49:48 -0700 Subject: [PATCH 20/49] sessions: Sync Cloud Sandbox archives through Mission Control (#339608) * sessions: Sync Cloud Sandbox archives through Mission Control Route task archive and unarchive operations through Mission Control without waking sandbox environments. Discover archived tasks and reconcile their flags in both window types, preserving archived history and surfacing rejected mutations. Add cross-client, persistence, ordering, and remote-provider regression coverage. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Harden Cloud Sandbox archive reconciliation Persist explicit unarchived task flags, cancel queued archive mutations when their provider is disposed, and notify Editor users when mutations fail. Add focused restoration, lifecycle, and error-reporting regression coverage. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/cloudSandboxAgentHost.ts | 5 + .../REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md | 6 +- .../cloudSandboxAgentHostContribution.ts | 1 + .../browser/cloudSandboxSessionsProvider.ts | 47 ++++++- .../cloudSandboxAgentHostContribution.test.ts | 120 +++++++++++++++- .../remoteAgentHostSessionsProvider.test.ts | 130 +++++++++++++++++- .../remoteAgentHost/cloudSandboxApiService.ts | 29 ++-- .../cloudSandboxSessionContribution.ts | 38 ++++- .../cloudSandboxSessionListController.ts | 48 ++++++- .../remoteAgentHost/cloudSandboxTelemetry.ts | 2 +- .../editorCloudSandboxContribution.ts | 7 + .../cloudSandboxApiService.test.ts | 127 ++++++++++++++--- .../editorCloudSandboxContribution.test.ts | 112 +++++++++++++-- 13 files changed, 609 insertions(+), 63 deletions(-) diff --git a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts index 9d4afd43e1e8..def6d36c19c2 100644 --- a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts +++ b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts @@ -122,6 +122,8 @@ export interface ICloudSandboxDiscoveredSession { readonly updatedAt?: string; /** Last reported activity; this does not establish environment availability or session flags. */ readonly status?: SessionStatus; + /** Mission Control's archive state, independent of the sandbox's AHP session flags. */ + readonly isArchived?: boolean; } /** Build the synthesized remote-agent-host address for a sandbox environment. */ @@ -277,6 +279,9 @@ export interface ICloudSandboxApiService { /** Rename the Mission Control task without waking the sandbox. */ renameTask(taskId: string, title: string, token: CancellationToken): Promise; + /** Archive or unarchive the Mission Control task without waking the sandbox. */ + setTaskArchived(taskId: string, archived: boolean, token: CancellationToken): Promise; + /** * Read a task's persisted AHP history and fold it back into session and chat state. Served by * Mission Control's mirror, so it works without the sandbox. `undefined` when there is none. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md index 333694b6f522..02e78369e419 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md @@ -22,6 +22,8 @@ Cloud sandbox environments are automatically trusted like Codespaces. The sandbo Remote providers may delegate deletion to their inventory owner instead of AHP. In both windows, sandbox deletion addresses the owning Mission Control task without connecting to the environment; successful deletion removes the cached session, persisted discovery inventory, and environment provider. Failed deletion retains those entries, and stale discovery responses cannot restore a successfully deleted task. +In both windows, archiving and unarchiving an inventory-owned sandbox session updates its Mission Control task without waking the environment. Local archive state changes only after the request succeeds. Discovery includes archived tasks and reconciles their archive flags even while connected; archived sessions retain their provider and history so they remain available through the archive filter. Mission Control, rather than AHP, owns these task archive flags. Additional sessions sharing the environment continue to use AHP. + In the Agents Window, renaming a discovered sandbox session updates its owning Mission Control task without waking the environment. After success, the provider updates the local title and sends an AHP rename only if already connected; offline renames are not queued for replay. Host-reported titles remain authoritative on connection. Sessions sharing the environment but not owned by the discovered task continue to use AHP. The sandbox contribution saves a minimal discovery inventory in machine-local profile storage, separately for each authentication provider and account. Once the current account is known, it restores providers and cached rows before awaiting network discovery, without waking environments. Failed or partial discovery retains unconfirmed entries. Account changes remove the previous account's providers; credential refreshes for the same account preserve them. No credentials are stored in the inventory. @@ -45,9 +47,9 @@ Provider-owned session resolution policies retain the remote address for the pro The remote Agent Host service owns client-local display-name overrides in machine-local application storage, keyed by normalized connection address. Overrides take precedence over configured or discovered names in provider and resource labels without changing connection details or routing identities; clearing an override restores the current default name. -In the Editor Window, a chat session contribution's `sessionListGroup` selects its provider filter without changing its controller, resource scheme, or content-provider routing. Disconnected discovery supplies activity, not authoritative read/archive flags or proof that the host is available. +In the Editor Window, a chat session contribution's `sessionListGroup` selects its provider filter without changing its controller, resource scheme, or content-provider routing. Disconnected discovery supplies activity and task archive flags, not authoritative read flags or proof that the host is available. -Both sandbox adapters let fresh discovery update disk-cached activity while preserving host-owned workspace information and user flags. Host-reported activity takes precedence over discovery for the rest of that adapter's lifetime, including after disconnection; older discovery responses cannot replace a newer discovery result. The Agents Window's persisted discovery baselines let title, timestamp, and project fields continue to refresh until the host changes them. Missing activity does not clear a previously reported status. Sandbox connection availability and read-only interactivity remain separate from conversation activity, so disconnection does not turn a reported input request into a conversation error. +Both sandbox adapters let fresh discovery update disk-cached activity while preserving host-owned workspace information and read flags. Host-reported activity takes precedence over discovery for the rest of that adapter's lifetime, including after disconnection; older discovery responses cannot replace a newer discovery result. The Agents Window's persisted discovery baselines let title, timestamp, and project fields continue to refresh until the host changes them. Missing activity does not clear a previously reported status. Sandbox connection availability and read-only interactivity remain separate from conversation activity, so disconnection does not turn a reported input request into a conversation error. In the Agents Window, all sandbox sessions discovered through Mission Control are non-external, regardless of which client created them or the host's external-session metadata. This classification applies to discovery, connected sessions, and cached summaries restored in any VS Code profile. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts index 4867a703b4f3..dc1988b6cd61 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts @@ -107,6 +107,7 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri const rawId = env.sessionId; const address = cloudSandboxAddress(env.environmentId); this._providerInstances.get(address)?.setTaskRenameHandler(rawId, title => this._renameSandboxSession(address, rawId, title)); + this._providerInstances.get(address)?.setTaskArchiveHandler(rawId, archived => this._setSandboxSessionArchived(address, rawId, archived)); } } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts index b7915f499078..b54483c440de 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Sequencer } from '../../../../../base/common/async.js'; import { CancellationError } from '../../../../../base/common/errors.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../../base/common/observable.js'; @@ -32,6 +33,8 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide override get automations(): undefined { return undefined; } private _taskRenameHandler: { readonly rawId: string; readonly rename: (title: string) => Promise } | undefined; + private _taskArchiveHandler: { readonly rawId: string; readonly setArchived: (archived: boolean) => Promise } | undefined; + private readonly _archiveSequencer = new Sequencer(); /** * Provisional sessions kept out of {@link getSessions} because the caller is still showing a @@ -72,7 +75,14 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide } protected override _resolveArchivedState(rawId: string, isArchived: boolean): boolean { - return this._sessionCache.get(rawId)?.isArchived.get() ?? isArchived; + return this._taskArchiveHandler?.rawId === rawId + ? this._sessionCache.get(rawId)?.isArchived.get() ?? isArchived + : super._resolveArchivedState(rawId, isArchived); + } + + /** Bind the discovered session's Mission Control archive operation. */ + setTaskArchiveHandler(rawId: string, setArchived: (archived: boolean) => Promise): void { + this._taskArchiveHandler = { rawId, setArchived }; } /** Bind the discovered session's Mission Control rename operation. */ @@ -105,22 +115,45 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide } override async archiveSession(sessionId: string): Promise { - this._setLocalArchived(sessionId, true); + await this._archiveSequencer.queue(() => this._setArchived(sessionId, true)); } override async unarchiveSession(sessionId: string): Promise { - this._setLocalArchived(sessionId, false); + await this._archiveSequencer.queue(() => this._setArchived(sessionId, false)); } - private _setLocalArchived(sessionId: string, isArchived: boolean): void { + private async _setArchived(sessionId: string, isArchived: boolean): Promise { + if (this._store.isDisposed) { + throw new CancellationError(); + } const rawId = this._rawIdFromChatId(sessionId); const session = rawId ? this._sessionCache.get(rawId) : undefined; + if (!session || !rawId) { + throw new Error(localize('cloudSandbox.sessionNotFound', "Sandbox session not found.")); + } + const handler = this._taskArchiveHandler; + if (handler?.rawId !== rawId) { + if (!this.connection) { + throw new Error(localize('cloudSandbox.archiveUnavailable', "Connect to the environment to change this session's archive state.")); + } + return isArchived ? super.archiveSession(sessionId) : super.unarchiveSession(sessionId); + } + await handler.setArchived(isArchived); + if (this._store.isDisposed || this._sessionCache.get(rawId) !== session) { + throw new CancellationError(); + } + this.setSessionArchived(rawId, isArchived); + } + + setSessionArchived(rawId: string, archived: boolean): void { + const session = this._sessionCache.get(rawId); if (!session) { throw new Error(localize('cloudSandbox.sessionNotFound', "Sandbox session not found.")); } - - // TODO: Reconcile local archive state with Mission Control so sessions are archived across clients. - session.isArchived.set(isArchived, undefined); + if (session.isArchived.get() === archived) { + return; + } + session.isArchived.set(archived, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [session] }); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts index 465366cab1b2..434b8d09ee47 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts @@ -70,6 +70,22 @@ class StubProvider extends mock() { override readonly connectionStatus: IObservable = this._status; disposed = false; taskRenameHandler: { readonly rawId: string; readonly rename: (title: string) => Promise } | undefined; + taskArchiveHandler: { readonly rawId: string; readonly setArchived: (archived: boolean) => Promise } | undefined; + readonly archiveUpdates: { rawId: string; archived: boolean }[] = []; + + override setTaskArchiveHandler(rawId: string, setArchived: (archived: boolean) => Promise): void { + this.taskArchiveHandler = { rawId, setArchived }; + } + + override setSessionArchived(rawId: string, archived: boolean): void { + this.archiveUpdates.push({ rawId, archived }); + const index = this.seeded.findIndex(meta => AgentSession.id(meta.session) === rawId); + if (index !== -1) { + const meta = this.seeded[index]; + const status = meta.status ?? SessionStatus.Idle; + this.seeded[index] = { ...meta, status: archived ? status | SessionStatus.IsArchived : status & ~SessionStatus.IsArchived }; + } + } override setTaskRenameHandler(rawId: string, rename: (title: string) => Promise): void { this.taskRenameHandler = { rawId, rename }; @@ -231,6 +247,7 @@ interface ITestHarness { readonly historyRequests: string[]; readonly deletedTasks: string[]; readonly renamedTasks: { taskId: string; title: string }[]; + readonly archivedTasks: { taskId: string; archived: boolean }[]; /** Host groups currently declared to the filter service. */ readonly hostGroups: IAgentHostGroup[]; readonly discoveryModes: boolean[]; @@ -251,6 +268,7 @@ async function createContribution(store: Pick, sessions: readonly getEnvironment?: (id: string, token: CancellationToken) => Promise; readonly deleteTask?: (taskId: string, token: CancellationToken) => Promise; readonly renameTask?: (taskId: string, title: string, token: CancellationToken) => Promise; + readonly setTaskArchived?: (taskId: string, archived: boolean, token: CancellationToken) => Promise; /** Whether the sandbox feature settings start on. Defaults to `true`. */ readonly enabled?: boolean; readonly aiDisabled?: boolean; @@ -270,6 +288,7 @@ async function createContribution(store: Pick, sessions: const historyRequests: string[] = []; const deletedTasks: string[] = []; const renamedTasks: { taskId: string; title: string }[] = []; + const archivedTasks: { taskId: string; archived: boolean }[] = []; const onDidChangeSentiment = store.add(new Emitter()); let chatHidden = options?.chatHidden ?? false; const discoveryModes: boolean[] = []; @@ -292,6 +311,7 @@ async function createContribution(store: Pick, sessions: historyRequests, deletedTasks, renamedTasks, + archivedTasks, hostGroups, discoveryModes, changeAccount: value => { @@ -360,6 +380,10 @@ async function createContribution(store: Pick, sessions: renamedTasks.push({ taskId, title }); await options?.renameTask?.(taskId, title, token); } + override async setTaskArchived(taskId: string, archived: boolean, token: CancellationToken): Promise { + archivedTasks.push({ taskId, archived }); + await options?.setTaskArchived?.(taskId, archived, token); + } }()); instantiationService.stub(ICloudSandboxAgentHostService, new class extends mock() { override async connect(connectOptions: ICloudSandboxConnectOptions, token: CancellationToken): Promise { @@ -461,6 +485,88 @@ suite('CloudSandboxAgentHostContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('archives the owning task without connecting and persists the flag for offline restoration', async () => { + const storage = store.add(new InMemoryStorageService()); + const harness = await createContribution(store, [discoveredSession()], { storageService: storage }); + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!; + await provider.taskArchiveHandler!.setArchived(true); + harness.contribution.dispose(); + const restored = await createContribution(store, [], { + storageService: storage, listSessions: async () => ({ kind: 'failed', reason: 'offline' }), + }); + assert.deepStrictEqual({ + rawId: provider.taskArchiveHandler!.rawId, + archivedTasks: harness.archivedTasks, connectedTo: harness.connectedTo, + restoredArchived: !!(restored.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!.seeded[0].status! & SessionStatus.IsArchived), + }, { + rawId: 'sess-1', archivedTasks: [{ taskId: 'task-1', archived: true }], connectedTo: [], restoredArchived: true, + }); + }); + + test('reconciles task archive and unarchive updates from another client without removing the provider', async () => { + const harness = await createContribution(store, [discoveredSession()]); + const address = cloudSandboxAddress('env-1'); + const provider = harness.contribution.stubProviders.get(address)!; + harness.discovered = [discoveredSession({ isArchived: true })]; + await harness.runDiscovery(); + const archived = !!(provider.seeded[0].status! & SessionStatus.IsArchived); + harness.discovered = [discoveredSession({ isArchived: false })]; + await harness.runDiscovery(); + assert.deepStrictEqual({ + archived, unarchived: !(provider.seeded[0].status! & SessionStatus.IsArchived), + sameProvider: harness.contribution.stubProviders.get(address) === provider, disposed: provider.disposed, + }, { archived: true, unarchived: true, sameProvider: true, disposed: false }); + }); + + test('restores explicit unarchived inventory state without network discovery', async () => { + const storage: IStorageService = store.add(new InMemoryStorageService()); + const session = discoveredSession({ isArchived: true }); + const harness = await createContribution(store, [session], { storageService: storage }); + harness.discovered = [{ ...session, isArchived: false }]; + await harness.runDiscovery(); + harness.contribution.dispose(); + const restored = await createContribution(store, [], { + storageService: storage, listSessions: async () => ({ kind: 'failed', reason: 'offline' }), + }); + const key = `sessions.cloudSandbox.inventory.["github","account-1"].${JSON.stringify([session.environmentId, session.sessionId])}`; + const inventory = storage.getObject<{ sessions: ICloudSandboxDiscoveredSession[] }>(key, StorageScope.PROFILE); + assert.deepStrictEqual({ + persisted: inventory?.sessions[0].isArchived, + restoredUpdates: restored.contribution.stubProviders.get(cloudSandboxAddress(session.environmentId))!.archiveUpdates, + }, { persisted: false, restoredUpdates: [{ rawId: session.sessionId, archived: false }] }); + }); + + test('does not persist rejected task archives', async () => { + const storage = store.add(new InMemoryStorageService()); + const harness = await createContribution(store, [discoveredSession()], { + storageService: storage, setTaskArchived: async () => { throw new Error('archive rejected'); }, + }); + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!; + await assert.rejects(provider.taskArchiveHandler!.setArchived(true), /archive rejected/); + harness.contribution.dispose(); + const restored = await createContribution(store, [], { + storageService: storage, listSessions: async () => ({ kind: 'failed', reason: 'offline' }), + }); + assert.strictEqual(!!(restored.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!.seeded[0].status! & SessionStatus.IsArchived), false); + }); + + test('cancels task archive updates when the account changes', async () => { + const pending = new DeferredPromise(); + const entered = new DeferredPromise(); + const harness = await createContribution(store, [discoveredSession()], { + setTaskArchived: async () => { + await entered.complete(); + await pending.p; + }, + }); + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!; + const archive = provider.taskArchiveHandler!.setArchived(true); + await entered.p; + harness.changeAccount(undefined); + await pending.complete(); + await assert.rejects(archive, CancellationError); + }); + test('renames the discovered task without connecting and persists its new inventory name', async () => { const storage = store.add(new InMemoryStorageService()); const harness = await createContribution(store, [discoveredSession()], { storageService: storage }); @@ -1162,7 +1268,7 @@ suite('CloudSandboxAgentHostContribution startup inventory', () => { }, { offlineTrusted: true, previousRegistrationReleased: false, - cached: [session], + cached: [{ ...session, isArchived: false }], machineKeys: [entryKey(session)], seeded: [{ id: session.sessionId, title: session.name, modifiedTime: Date.parse(session.updatedAt!), repository: session.repoName }], connected: [], history: [], @@ -1209,8 +1315,8 @@ suite('CloudSandboxAgentHostContribution startup inventory', () => { assert.deepStrictEqual({ merged, afterRemoval: readInventory(storageService), }, { - merged: [discoveredSession(), other], - afterRemoval: [other], + merged: [discoveredSession({ isArchived: false }), { ...other, isArchived: false }], + afterRemoval: [{ ...other, isArchived: false }], }); }); @@ -1299,7 +1405,7 @@ suite('CloudSandboxAgentHostContribution startup inventory', () => { }, { otherAccountRows: 0, restoredImmediately: true, hiddenOnSignOut: true, signedOutRows: 0, signedOutRequests: [], - saved: [discoveredSession()], + saved: [discoveredSession({ isArchived: false })], }); }); @@ -1367,8 +1473,8 @@ suite('CloudSandboxAgentHostContribution startup inventory', () => { currentAccount: readInventory(storageService, otherAccount), }, { cancelled: true, visible: [cloudSandboxAddress('env-2')], - previousAccount: [discoveredSession()], - currentAccount: sessions, + previousAccount: [discoveredSession({ isArchived: false })], + currentAccount: sessions.map(session => ({ ...session, isArchived: false })), }); }); @@ -1435,7 +1541,7 @@ suite('CloudSandboxAgentHostContribution startup inventory', () => { await second.runDiscovery(); assert.deepStrictEqual({ afterUpdate, afterRemoval: readInventory(shared) }, { - afterUpdate: [updated], + afterUpdate: [{ ...updated, isArchived: false }], afterRemoval: [], }); }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 6c309302ed53..4e49dadd7f64 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -3971,6 +3971,8 @@ suite('CloudSandboxSessionsProvider discovery status', () => { workingDirectory: URI.file('/remote/project'), }); const first = sandbox(storage); + assert.ok(first instanceof CloudSandboxSessionsProvider); + first.setTaskArchiveHandler('discovered', async () => { }); first.seedSessions([metadata]); await first.archiveSession(first.getSessions()[0].sessionId); await storage.flush(); @@ -4325,19 +4327,26 @@ suite('CloudSandboxSessionsProvider archiving', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createSandboxProvider(overrides?: { storageService?: IStorageService; connectOnDemand?: () => Promise }): RemoteAgentHostSessionsProvider { - return createProvider(disposables, connection, { + function createSandboxProvider(overrides?: { storageService?: IStorageService; connectOnDemand?: () => Promise; setArchived?: (archived: boolean) => Promise }): CloudSandboxSessionsProvider { + const provider = createProvider(disposables, connection, { address: 'cloudsandbox:archive-test', sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, ctor: CloudSandboxSessionsProvider, noConnection: true, ...overrides, }); + assert.ok(provider instanceof CloudSandboxSessionsProvider); + provider.setTaskArchiveHandler('sandbox-session', overrides?.setArchived ?? (async () => { })); + return provider; } - test('archives and unarchives without connecting or dispatching host actions', async () => { + test('archives and unarchives the owning task without connecting or dispatching host actions', async () => { let connectCalls = 0; - const provider = createSandboxProvider({ connectOnDemand: async () => { connectCalls++; } }); + const taskArchives: boolean[] = []; + const provider = createSandboxProvider({ + connectOnDemand: async () => { connectCalls++; }, + setArchived: async archived => { taskArchives.push(archived); }, + }); provider.seedSessions([metadata]); const session = provider.getSessions()[0]; const archivedStates: boolean[] = []; @@ -4350,13 +4359,126 @@ suite('CloudSandboxSessionsProvider archiving', () => { archivedStates, connectCalls, hostActions: connection.dispatchedActions, + taskArchives, }, { archivedStates: [false, true, false], connectCalls: 0, hostActions: [], + taskArchives: [true, false], }); }); + test('retains archive state when Mission Control rejects a mutation', async () => { + const provider = createSandboxProvider({ setArchived: async () => { throw new Error('archive rejected'); } }); + provider.seedSessions([metadata]); + const session = provider.getSessions()[0]; + await assert.rejects(provider.archiveSession(session.sessionId), /archive rejected/); + const afterArchive = session.isArchived.get(); + provider.setSessionArchived('sandbox-session', true); + await assert.rejects(provider.unarchiveSession(session.sessionId), /archive rejected/); + assert.deepStrictEqual({ afterArchive, afterUnarchive: session.isArchived.get() }, { afterArchive: false, afterUnarchive: true }); + }); + + test('serializes task mutations and changes local state only after success', async () => { + const pending = new DeferredPromise(); + const entered = new DeferredPromise(); + const mutations: boolean[] = []; + const provider = createSandboxProvider({ + setArchived: async archived => { + mutations.push(archived); + if (archived) { + await entered.complete(); + await pending.p; + } + } + }); + provider.seedSessions([metadata]); + const session = provider.getSessions()[0]; + const archive = provider.archiveSession(session.sessionId); + await entered.p; + const unarchive = provider.unarchiveSession(session.sessionId); + const before = { archived: session.isArchived.get(), mutations: [...mutations] }; + await pending.complete(); + await Promise.all([archive, unarchive]); + assert.deepStrictEqual({ before, after: { archived: session.isArchived.get(), mutations } }, { + before: { archived: false, mutations: [true] }, + after: { archived: false, mutations: [true, false] }, + }); + }); + + test('cancels queued task mutations when the provider is disposed', async () => { + const pending = new DeferredPromise(); + const entered = new DeferredPromise(); + const mutations: boolean[] = []; + const provider = createSandboxProvider({ + setArchived: async archived => { + mutations.push(archived); + await entered.complete(); + await pending.p; + }, + }); + provider.seedSessions([metadata]); + const session = provider.getSessions()[0]; + const archive = assert.rejects(provider.archiveSession(session.sessionId), CancellationError); + await entered.p; + const unarchive = assert.rejects(provider.unarchiveSession(session.sessionId), CancellationError); + provider.dispose(); + await pending.complete(); + await Promise.all([archive, unarchive]); + assert.deepStrictEqual(mutations, [true]); + }); + + test('applies archive and unarchive discoveries from another client despite conflicting host state', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createSandboxProvider(); + provider.seedSessions([metadata]); + connection.addSession({ ...metadata, session: backendResource, status: ProtocolSessionStatus.Idle }); + provider.setConnection(connection); + await timeout(0); + const session = provider.getSessions()[0]; + provider.setSessionArchived('sandbox-session', true); + connection.fireNotification({ + channel: 'ahp-root://', type: NotificationType.SessionSummaryChanged, + session: backendResource.toString(), changes: { status: ProtocolSessionStatus.Idle }, + }); + const archived = session.isArchived.get(); + provider.setSessionArchived('sandbox-session', false); + connection.fireAction({ + channel: backendResource.toString(), + action: { type: ActionType.SessionIsArchivedChanged, isArchived: true }, + serverSeq: 1, origin: undefined, + }); + assert.deepStrictEqual({ archived, unarchived: session.isArchived.get() }, { archived: true, unarchived: false }); + })); + + test('rejects offline mutations for sessions without an owning task', async () => { + const provider = createSandboxProvider(); + provider.seedSessions([createSession('additional-session')]); + const session = provider.getSessions()[0]; + await assert.rejects(provider.archiveSession(session.sessionId), /Connect to the environment/); + assert.strictEqual(session.isArchived.get(), false); + }); + + test('uses AHP for additional sessions sharing the sandbox', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createSandboxProvider(); + connection.addSession({ ...createSession('additional-session', { provider: 'copilot' }), session: AgentSession.uri('ahp-session', 'additional-session') }); + provider.setConnection(connection); + await timeout(0); + const session = provider.getSessions()[0]; + await provider.archiveSession(session.sessionId); + connection.fireAction({ + channel: 'ahp-session:/additional-session', + action: { type: ActionType.SessionIsArchivedChanged, isArchived: false }, + serverSeq: 1, origin: undefined, + }); + assert.deepStrictEqual({ + archived: session.isArchived.get(), + actions: connection.dispatchedActions.map(({ channel, action }) => ({ channel, action })), + }, { + archived: false, + actions: [{ channel: 'ahp-session:/additional-session', action: { type: ActionType.SessionIsArchivedChanged, isArchived: true } }], + }); + })); + test('restores local archive and unarchive choices through rediscovery and reconnect', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const storageService = disposables.add(new InMemoryStorageService()); const provider = createSandboxProvider({ storageService }); diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts index f1d4e11eaa90..d1c3de35b422 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts @@ -294,7 +294,10 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA let checkpoint: number | undefined; let latestUpdate: number | undefined; // Separate repository scopes include workspace-less sandboxes as well as repository sessions. - for (const withRepository of [true, false]) { + for (const { withRepository, archived } of [ + { withRepository: true, archived: false }, { withRepository: false, archived: false }, + { withRepository: true, archived: true }, { withRepository: false, archived: true }, + ]) { for (let page = 1; page <= DISCOVERY_TASK_PAGE_LIMIT; page++) { if (token.isCancellationRequested) { throw new CancellationError(); @@ -305,6 +308,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA sort: 'updated_at', direction: 'desc', with_repo: String(withRepository), + is_archived: String(archived), ...(since ? { since, include_environment_kinds: 'managed-sandbox' } : {}), }; try { @@ -357,7 +361,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA const removedTaskIds: string[] = []; const sandboxTasks: ITaskSummary[] = []; for (const task of tasks.values()) { - if (!task.archived_at && isCloudSandboxTask(task)) { + if (isCloudSandboxTask(task)) { sandboxTasks.push(task); } else { if (cache.has(task.id) || isCloudSandboxTask(task)) { @@ -376,17 +380,12 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA throw new CancellationError(); } let cached = cache.get(task.id); - if (cached?.needsRefresh || !cached?.session || !task.updated_at || task.updated_at !== cached.summary.updated_at) { + if (cached?.needsRefresh || !cached?.session || !task.updated_at || task.updated_at !== cached.summary.updated_at || task.archived_at !== cached.summary.archived_at) { const context = await this._sendTask(`${this._tasksBaseUrl()}/tasks/${encodeURIComponent(task.id)}`, 'get', token); const full = await this._readJson(context); if (!full) { throw new Error('getTask returned no task'); } - if (full.archived_at) { - removedTaskIds.push(task.id); - cache.delete(task.id); - return undefined; - } const binding = getTaskEnvironmentBinding(full); if (!binding && cached?.session) { removedTaskIds.push(task.id); @@ -404,6 +403,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA name: full.name ?? task.name ?? `Sandbox ${task.id}`, updatedAt: full.updated_at ?? task.updated_at, ...(status !== undefined ? { status } : {}), + ...(full.archived_at ? { isArchived: true } : {}), } : undefined, }; } @@ -556,6 +556,19 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA this._discoveryGeneration++; } + async setTaskArchived(taskId: string, archived: boolean, token: CancellationToken): Promise { + const action = archived ? 'archive' : 'unarchive'; + const context = await this._request(`${this._tasksBaseUrl()}/tasks/${encodeURIComponent(taskId)}/${action}`, `mc.taskClient.${action}`, archived ? 'archiveTask' : 'unarchiveTask', { + 'Accept': 'application/json', + 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, + }, token, REQUEST_TIMEOUT_MS, undefined, 'POST'); + if (!isSuccess(context)) { + await this._throwForStatus(`task ${action}`, context); + } + this._discoveredTasks.delete(taskId); + this._discoveryGeneration++; + } + /** * Delete a task we created but cannot use. Best-effort: the caller is already failing, and a * failed cleanup must not replace the error that explains why. diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts index bd5e284978de..073ac30e68aa 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts @@ -64,6 +64,7 @@ export interface ICloudSandboxSessionEnvironment { readonly name: string; readonly repoName?: string; readonly updatedAt?: string; + readonly isArchived?: boolean; } function isDiscoveredSandboxSession(value: unknown): value is ICloudSandboxDiscoveredSession { @@ -75,7 +76,8 @@ function isDiscoveredSandboxSession(value: unknown): value is ICloudSandboxDisco && typeof candidate.name === 'string' && (candidate.eventType === undefined || typeof candidate.eventType === 'string') && (candidate.repoName === undefined || typeof candidate.repoName === 'string') - && (candidate.updatedAt === undefined || typeof candidate.updatedAt === 'string'); + && (candidate.updatedAt === undefined || typeof candidate.updatedAt === 'string') + && (candidate.isArchived === undefined || typeof candidate.isArchived === 'boolean'); } /** @@ -100,6 +102,7 @@ export interface ICloudSandboxSessionList extends IDisposable { setLabel?(label: string): void; setConnection(connection: IAgentConnection, defaultDirectory: string | undefined): void; setConnectionStatus(status: RemoteAgentHostConnectionStatus): void; + setSessionArchived(rawId: string, archived: boolean): void; } export abstract class CloudSandboxSessionContribution extends Disposable implements IWorkbenchContribution { @@ -301,7 +304,7 @@ export abstract class CloudSandboxSessionContribution { + const environment = this._environments.get(address); + if (!environment?.taskId || environment.sessionId !== rawId) { + throw new Error(localize('cloudSandbox.archiveSessionNotFound', "Mission Control sandbox session not found.")); + } + if (token.isCancellationRequested) { + throw new CancellationError(); + } + const store = new DisposableStore(); + const source = store.add(new CancellationTokenSource(this._enabledCts.token)); + store.add(token.onCancellationRequested(() => source.cancel())); + try { + await this._apiService.setTaskArchived(environment.taskId, archived, source.token); + if (source.token.isCancellationRequested) { + throw new CancellationError(); + } + } finally { + store.dispose(); + } + const current = this._environments.get(address); + if (current?.taskId !== environment.taskId || current.sessionId !== rawId) { + throw new CancellationError(); + } + this._environments.set(address, { ...current, isArchived: archived }); + this._persistInventory(); + } + protected async _deleteSandboxSession(address: string, sessionIds: readonly string[], removeSession: (rawId: string) => void, token: CancellationToken = CancellationToken.None): Promise { const environment = this._environments.get(address); if (!environment?.taskId || !environment.sessionId || sessionIds.some(id => id !== environment.sessionId)) { diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionListController.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionListController.ts index c8f6fed1e519..507357bb9fd2 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionListController.ts @@ -3,9 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Sequencer } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { Emitter } from '../../../../../base/common/event.js'; +import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -19,6 +22,7 @@ import { CLOUD_SANDBOX_AGENT_PROVIDER, CLOUD_SANDBOX_SESSION_SCHEME } from '../. import { RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { getGitHubRepositoryFromRemoteUrl } from '../../../git/common/utils.js'; import { IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta } from '../../common/chatSessionsService.js'; import { AgentHostSessionListController } from '../agentSessions/agentHost/agentHostSessionListController.js'; @@ -40,13 +44,17 @@ export class CloudSandboxSessionListController extends Disposable implements ICl private readonly _connection = observableValue(this, undefined); private readonly _discoveredRepositories = observableValue>(this, new Map()); private readonly _items: IObservable; + private readonly _archivedStates = observableValue>(this, new Map()); + private readonly _archiveSequencer = new Sequencer(); constructor( address: string, workspaceRepositories: IObservable | undefined>, private readonly _deleteSession: (rawId: string, token: CancellationToken) => Promise, + private readonly _archiveSession: (rawId: string, archived: boolean, token: CancellationToken) => Promise, @IInstantiationService instantiationService: IInstantiationService, @IRemoteAgentHostAuthenticationService authenticationService: IRemoteAgentHostAuthenticationService, + @INotificationService private readonly _notificationService: INotificationService, ) { super(); const authority = agentHostAuthority(address); @@ -71,6 +79,7 @@ export class CloudSandboxSessionListController extends Disposable implements ICl const repositories = workspaceRepositories.read(reader); const discoveredRepositories = this._discoveredRepositories.read(reader); const connected = !!this._connection.read(reader); + const archivedStates = this._archivedStates.read(reader); const visibleSessions = repositories && new Set(this._sessionListStore.getSessions(CLOUD_SANDBOX_AGENT_PROVIDER).filter(entry => { const projectUri = entry.summary.project?.uri; const project = projectUri ? getGitHubRepositoryFromRemoteUrl(projectUri) : undefined; @@ -83,7 +92,7 @@ export class CloudSandboxSessionListController extends Disposable implements ICl }).map(entry => entry.rawId)); return this._controller.items .filter(item => !visibleSessions || visibleSessions.has(AgentSession.id(item.resource))) - .map(item => this._listItem(item, connected)); + .map(item => this._listItem(item, connected, archivedStates)); }); let previousResources = new ResourceSet(); this._register(autorun(reader => { @@ -184,11 +193,46 @@ export class CloudSandboxSessionListController extends Disposable implements ICl this._sessionListStore.removeSession(CLOUD_SANDBOX_AGENT_PROVIDER, rawId); } - private _listItem(item: IChatSessionItem, connected: boolean): IChatSessionItem { + setSessionArchived(rawId: string, archived: boolean): void { + if (this._archivedStates.get().get(rawId) === archived) { + return; + } + const states = new Map(this._archivedStates.get()); + states.set(rawId, archived); + this._archivedStates.set(states, undefined); + } + + setChatSessionItemArchived(resource: URI, archived: boolean): void { + void this._archiveSequencer.queue(async () => { + if (this._store.isDisposed) { + throw new CancellationError(); + } + if (resource.scheme !== this.sessionType || resource.fragment) { + throw new Error(localize('cloudSandbox.archiveInvalidSession', "The session does not belong to this sandbox.")); + } + const rawId = AgentSession.id(resource); + if (await this._archiveSession(rawId, archived, CancellationToken.None)) { + if (this._store.isDisposed) { + throw new CancellationError(); + } + this.setSessionArchived(rawId, archived); + } else { + this._requireConnection(); + this._controller.setChatSessionItemArchived(resource, archived); + } + }).catch(error => { + if (!isCancellationError(error)) { + this._notificationService.error(localize('cloudSandbox.archiveFailed', "Unable to change the sandbox session's archive state: {0}", toErrorMessage(error))); + } + }); + } + + private _listItem(item: IChatSessionItem, connected: boolean, archivedStates: ReadonlyMap): IChatSessionItem { return { ...item, iconPath: Codicon.cloud, ...(!connected || item.isRead === undefined ? { archived: undefined, isRead: undefined } : {}), + ...(archivedStates.has(AgentSession.id(item.resource)) ? { archived: archivedStates.get(AgentSession.id(item.resource)) } : {}), }; } diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts index 1a8fd859b8ae..632dab16027b 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts @@ -13,7 +13,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; /** The Mission Control call being reported. A closed set, so it is safe to send verbatim. */ -export type CloudSandboxRequestAction = 'connect' | 'reconnect' | 'getEnvironment' | 'listTasks' | 'getTask' | 'createTask' | 'deleteTask' | 'renameTask' | 'getTaskEvents' | 'getRepository'; +export type CloudSandboxRequestAction = 'connect' | 'reconnect' | 'getEnvironment' | 'listTasks' | 'getTask' | 'createTask' | 'deleteTask' | 'renameTask' | 'archiveTask' | 'unarchiveTask' | 'getTaskEvents' | 'getRepository'; /** * How a Mission Control request ended, bucketed so a count is meaningful without carrying the diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts index e3097ecf8c8a..90fa8e41482e 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts @@ -162,6 +162,13 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo } await this._deleteSandboxSession(address, [rawId], id => provider.removeDeletedSession(id), token); return true; + }, + async (rawId, archived, token) => { + if (!this._ownsSandboxSession(address, rawId)) { + return false; + } + await this._setSandboxSessionArchived(address, rawId, archived, token); + return true; })); store.add(this._connectionsService.registerSessionResolutionPolicy(agentHostAuthority(address), { connectionAddress: address, diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts index 3f8b28a968b3..828ec9989f2f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts @@ -157,6 +157,9 @@ function createService(store: Pick<{ add(t: T): T } const query = new URL(url).searchParams; const tasks = options.tasks.filter(task => { + if (!!task.archived_at !== (query.get('is_archived') === 'true')) { + return false; + } if (query.has('with_repo') && (task.repository?.id !== undefined) !== (query.get('with_repo') === 'true')) { return false; } @@ -582,7 +585,7 @@ suite('CloudSandboxApiService repository resolution', () => { }, { kind: 'complete', found: ['sess-old'], - listPages: 3, + listPages: 5, }); }); @@ -604,7 +607,7 @@ suite('CloudSandboxApiService repository resolution', () => { }, { kind: 'complete', found: ['sess-old'], - listPages: 3, + listPages: 5, }); }); @@ -623,7 +626,7 @@ suite('CloudSandboxApiService repository resolution', () => { }, { kind: 'partial', sessions: 1000, - listPages: 11, + listPages: 13, }); }); }); @@ -662,6 +665,8 @@ suite('CloudSandboxApiService discovery logs', () => { taskId: 'bound', sessionId: 'session-1', environmentId: 'env-1', name: 'Work on repository', repoName: 'owner/repository', updatedAt: bound.updated_at, status: SessionStatus.InputNeeded, + })}`, `[CloudSandboxApi] Discovered sandbox session ${JSON.stringify({ + taskId: 'archived', sessionId: 'session-2', environmentId: 'env-2', name: 'Old task', })}`], info: [], exposedPrompt: false, @@ -769,7 +774,7 @@ suite('CloudSandboxApiService stalled sandbox discovery', () => { }, { kind: 'complete', sessions: age >= oneHour ? [] : ['stalled'], - requests: ['/agents/tasks', '/agents/tasks', '/agents/tasks/stalled'], + requests: ['/agents/tasks', '/agents/tasks', '/agents/tasks', '/agents/tasks', '/agents/tasks/stalled'], }); })); } @@ -945,7 +950,7 @@ suite('CloudSandboxApiService incremental discovery', () => { return { ...task(id, id, repositoryId, `session-${id}`, `env-${id}`), updated_at: updatedAt }; } - test('queries both repository scopes and resolves no task details when nothing changed', async () => { + test('queries both repository and archive scopes and resolves no task details when nothing changed', async () => { const { service, requestedUrls } = createService(store, { tasks: [updatedTask('old')], repositories: new Map(), @@ -961,10 +966,10 @@ suite('CloudSandboxApiService incremental discovery', () => { queries: requestedUrls.map(url => Object.fromEntries(new URL(url).searchParams)), }, { result: { kind: 'incremental', sessions: [], removedTaskIds: [] }, - queries: [true, false].map(withRepository => ({ + queries: [false, true].flatMap(archived => [true, false].map(withRepository => ({ per_page: '100', page: '1', sort: 'updated_at', direction: 'desc', - with_repo: String(withRepository), since: checkpoint, include_environment_kinds: 'managed-sandbox', - })), + with_repo: String(withRepository), is_archived: String(archived), since: checkpoint, include_environment_kinds: 'managed-sandbox', + }))), }); }); @@ -1003,7 +1008,7 @@ suite('CloudSandboxApiService incremental discovery', () => { kind: result.kind, since: new URL(requestedUrls[0]).searchParams.get('since'), requests: requestedUrls.length, - }, { kind: 'incremental', since: '2026-09-22T09:57:00.000Z', requests: 2 }); + }, { kind: 'incremental', since: '2026-09-22T09:57:00.000Z', requests: 4 }); }); test('preserves incremental filters on every page even when pagination links omit them', async () => { @@ -1021,16 +1026,16 @@ suite('CloudSandboxApiService incremental discovery', () => { assert.deepStrictEqual({ kind: result.kind, count: result.kind === 'failed' ? 0 : result.sessions.length, - pages: queries.map(query => [query.get('with_repo'), query.get('page')]), + pages: queries.map(query => [query.get('with_repo'), query.get('is_archived'), query.get('page')]), retainedFilters: queries.every(query => query.get('since') === checkpoint && query.get('include_environment_kinds') === 'managed-sandbox'), }, { kind: 'incremental', count: 101, - pages: [['true', '1'], ['false', '1'], ['false', '2']], + pages: [['true', 'false', '1'], ['false', 'false', '1'], ['false', 'false', '2'], ['true', 'true', '1'], ['false', 'true', '1']], retainedFilters: true, }); }); - test('reports explicit task archives and discovers unarchives without retaining stale details', async () => { + test('discovers task archives and unarchives without removing their sessions', async () => { const tasks = [updatedTask('first')]; const { service, requestedUrls } = createService(store, { tasks, repositories: new Map(), discoveryDate: () => firstScanDate, @@ -1044,14 +1049,15 @@ suite('CloudSandboxApiService incremental discovery', () => { const unarchived = await service.listSessions(CancellationToken.None, { incremental: true }); assert.deepStrictEqual({ - archived, archivedRequests, - unarchived: unarchived.kind === 'failed' ? [] : unarchived.sessions.map(session => session.taskId), + archived: archived.kind === 'failed' ? archived : { kind: archived.kind, sessions: archived.sessions.map(session => [session.taskId, session.isArchived]), removedTaskIds: archived.kind === 'incremental' ? archived.removedTaskIds : [] }, + archivedRequests, + unarchived: unarchived.kind === 'failed' ? [] : unarchived.sessions.map(session => [session.taskId, session.isArchived]), detailFetches: requestedUrls.filter(url => url.endsWith('/tasks/first')).length, }, { - archived: { kind: 'incremental', sessions: [], removedTaskIds: ['first'] }, - archivedRequests: 2, - unarchived: ['first'], - detailFetches: 1, + archived: { kind: 'incremental', sessions: [['first', true]], removedTaskIds: [] }, + archivedRequests: 5, + unarchived: [['first', undefined]], + detailFetches: 2, }); }); @@ -1434,6 +1440,91 @@ function createServiceForCreate(store: Pick<{ add return { service: store.add(instantiationService.createInstance(CloudSandboxApiService)), calls, errors, warnings }; } +suite('CloudSandboxApiService task archiving', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('another client discovers a task archive and the original client discovers its unarchive', async () => { + const tasks: ITestTask[] = [task('task-1', 'Task', undefined, 'session-1', 'env-1')]; + const createClient = () => createService(store, { + tasks, repositories: new Map(), + onRequest: (url, _token, options) => { + if (options.type === 'POST') { + tasks[0] = { ...tasks[0], archived_at: url.pathname.endsWith('/archive') ? new Date().toISOString() : undefined }; + return jsonResponse({}); + } + return undefined; + }, + }).service; + const first = createClient(); + const second = createClient(); + await first.listSessions(CancellationToken.None); + await first.setTaskArchived('task-1', true, CancellationToken.None); + const archived = await second.listSessions(CancellationToken.None); + await second.setTaskArchived('task-1', false, CancellationToken.None); + const unarchived = await first.listSessions(CancellationToken.None, { incremental: true }); + assert.deepStrictEqual({ + archived: archived.kind === 'failed' ? archived : archived.sessions.map(session => [session.taskId, session.isArchived === true]), + unarchived: unarchived.kind === 'failed' ? unarchived : unarchived.sessions.map(session => [session.taskId, session.isArchived === true]), + }, { archived: [['task-1', true]], unarchived: [['task-1', false]] }); + }); + + test('posts archive and unarchive to the owning task without an environment request', async () => { + const { service, calls } = createServiceForCreate(store, {}); + await service.setTaskArchived('task/with spaces', true, CancellationToken.None); + await service.setTaskArchived('task/with spaces', false, CancellationToken.None); + assert.deepStrictEqual(calls.map(call => ({ + path: new URL(call.url).pathname, method: call.type, body: call.body, + timeout: call.timeout, integration: call.headers['Copilot-Integration-Id'], + })), [ + { path: '/agents/tasks/task%2Fwith%20spaces/archive', method: 'POST', body: undefined, timeout: 10_000, integration: COPILOT_INTEGRATION_ID }, + { path: '/agents/tasks/task%2Fwith%20spaces/unarchive', method: 'POST', body: undefined, timeout: 10_000, integration: COPILOT_INTEGRATION_ID }, + ]); + }); + + for (const archived of [true, false]) { + for (const statusCode of [400, 403, 404, 422, 429, 500]) { + test(`surfaces rejected task ${archived ? 'archive' : 'unarchive'}: HTTP ${statusCode}`, async () => { + const { service } = createServiceForCreate(store, { message: 'archive rejected' }, statusCode); + await assert.rejects(service.setTaskArchived('task-1', archived, CancellationToken.None), new RegExp(`task ${archived ? 'archive' : 'unarchive'} failed: HTTP ${statusCode}`)); + }); + } + } + + test('requires authentication before archiving', async () => { + const { service, requestedUrls } = createService(store, { + tasks: [], repositories: new Map(), authenticationSessions: async () => [], + }); + await assert.rejects(service.setTaskArchived('task-1', true, CancellationToken.None), /signed-in GitHub account/); + assert.deepStrictEqual(requestedUrls, []); + }); + + test('invalidates discovery responses started before an archive mutation', async () => { + const pending = new DeferredPromise(); + const entered = new DeferredPromise(); + let paused = false; + const tasks = [task('task-1', 'Task', undefined, 'session-1', 'env-1')]; + const { service } = createService(store, { + tasks, repositories: new Map(), + onRequest: async (url, _token, options) => { + if (options.type === 'POST') { + return jsonResponse({}); + } + if (paused && url.pathname.endsWith('/tasks/task-1')) { + await entered.complete(); + return pending.p; + } + return undefined; + }, + }); + paused = true; + const discovery = service.listSessions(CancellationToken.None); + await entered.p; + await service.setTaskArchived('task-1', true, CancellationToken.None); + await pending.complete(jsonResponse(tasks[0])); + assert.strictEqual((await discovery).kind, 'failed'); + }); +}); + suite('CloudSandboxApiService task renaming', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts index 0a830a833c31..1407e13e9ba4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts @@ -34,6 +34,7 @@ import { ContextKeyService } from '../../../../../../platform/contextkey/browser import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService, NotificationMessage } from '../../../../../../platform/notification/common/notification.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService, IWorkspaceFoldersChangeEvent, toWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; @@ -144,6 +145,7 @@ function createHarness(store: Pick, options?: { readonly openRepository?: (root: URI) => Promise; readonly connect?: (options: ICloudSandboxConnectOptions, token: CancellationToken) => Promise; readonly deleteTask?: (taskId: string, token: CancellationToken) => Promise; + readonly setTaskArchived?: (taskId: string, archived: boolean, token: CancellationToken) => Promise; }) { const instantiationService = store.add(new TestInstantiationService()); const configuration = new TestConfigurationService({ @@ -165,7 +167,11 @@ function createHarness(store: Pick, options?: { const contentProviders = new Map(); const initialRefreshes: Promise[] = []; const discoveryModes: boolean[] = []; - const calls = { discovered: 0, created: 0, connected: [] as ICloudSandboxConnectOptions[], connectTokens: [] as CancellationToken[], history: [] as string[], removed: [] as string[], repositoryErrors: [] as string[], deletedTasks: [] as string[], disposedSessions: [] as string[] }; + const errorNotifications: NotificationMessage[] = []; + instantiationService.stub(INotificationService, new class extends mock() { + override error(message: NotificationMessage): void { errorNotifications.push(message); } + }()); + const calls = { discovered: 0, created: 0, connected: [] as ICloudSandboxConnectOptions[], connectTokens: [] as CancellationToken[], history: [] as string[], removed: [] as string[], repositoryErrors: [] as string[], deletedTasks: [] as string[], disposedSessions: [] as string[], archivedTasks: [] as { taskId: string; archived: boolean }[] }; const state = { workspaceFolders: (options?.workspaceFolders ?? [workspaceFolder]).map(toWorkspaceFolder), repositories: [...(options?.repositories ?? [repository(['https://github.com/example/project.git'])])], @@ -264,6 +270,10 @@ function createHarness(store: Pick, options?: { calls.deletedTasks.push(taskId); await options?.deleteTask?.(taskId, token); } + override async setTaskArchived(taskId: string, archived: boolean, token: CancellationToken): Promise { + calls.archivedTasks.push({ taskId, archived }); + await options?.setTaskArchived?.(taskId, archived, token); + } }()); instantiationService.stub(ICloudSandboxAgentHostService, new class extends mock() { override async connect(connectOptions: ICloudSandboxConnectOptions, token: CancellationToken) { @@ -352,7 +362,7 @@ function createHarness(store: Pick, options?: { instantiationService.stub(IAgentHostNewSessionFolderService, new class extends mock() { }()); const contribution = store.add(instantiationService.createInstance(TestEditorCloudSandboxContribution)); return { - instantiationService, contribution, controllers, contributions, contentProviders, chatSessionsService, state, calls, policies, notifications, resolvers, sentimentChanged, accountChanged, authenticationPending, initialRefreshes, connectionsChanged, focusChanged, discoveryModes, workspaceTrust, + instantiationService, contribution, controllers, contributions, contentProviders, chatSessionsService, state, calls, policies, notifications, errorNotifications, resolvers, sentimentChanged, accountChanged, authenticationPending, initialRefreshes, connectionsChanged, focusChanged, discoveryModes, workspaceTrust, refresh: async () => { await contribution.refresh(CancellationToken.None); await Promise.all(initialRefreshes); @@ -394,6 +404,84 @@ function createHarness(store: Pick, options?: { suite('Editor cloud sandbox discovery', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('archives and unarchives a discovered task without connecting', async () => { + const h = createHarness(store); + await h.refresh(); + const controller = h.controllers.get(sessionType)!; + controller.setChatSessionItemArchived!(resource, true); + await timeout(0); + const archived = h.items()[0].archived; + controller.setChatSessionItemArchived!(resource, false); + await timeout(0); + assert.deepStrictEqual({ + archived, unarchived: h.items()[0].archived, + tasks: h.calls.archivedTasks, connected: h.calls.connected, + }, { + archived: true, unarchived: false, + tasks: [{ taskId: discovered.taskId, archived: true }, { taskId: discovered.taskId, archived: false }], + connected: [], + }); + }); + + test('reconciles remote task archive state while connected and after disconnection', async () => { + const h = createHarness(store); + await h.refresh(); + h.state.online = true; + await h.contribution.activate(); + await h.controllers.get(sessionType)!.refresh(CancellationToken.None); + h.state.result = { kind: 'complete', sessions: [{ ...discovered, isArchived: true }] }; + await h.refresh(); + const connectedArchived = h.items()[0].archived; + h.state.connected = false; + h.connectionsChanged.fire(); + const disconnectedArchived = h.items()[0].archived; + h.state.result = { kind: 'complete', sessions: [{ ...discovered, isArchived: false }] }; + await h.refresh(); + assert.deepStrictEqual({ + connectedArchived, disconnectedArchived, unarchived: h.items()[0].archived, + }, { connectedArchived: true, disconnectedArchived: true, unarchived: false }); + }); + + test('notifies the user when Mission Control rejects archiving and preserves the current flag', async () => { + const h = createHarness(store, { setTaskArchived: async () => { throw new Error('archive rejected'); } }); + await h.refresh(); + h.controllers.get(sessionType)!.setChatSessionItemArchived!(resource, true); + await timeout(0); + assert.deepStrictEqual({ + archived: h.items()[0].archived, notifications: h.errorNotifications, + }, { + archived: false, notifications: ['Unable to change the sandbox session\'s archive state: archive rejected'], + }); + }); + + test('drops old queued archive operations after disable and re-enable without notifying cancellation', async () => { + const pending = new DeferredPromise(); + const entered = new DeferredPromise(); + const h = createHarness(store, { + setTaskArchived: async () => { + await entered.complete(); + await pending.p; + }, + }); + await h.refresh(); + const controller = h.controllers.get(sessionType)!; + controller.setChatSessionItemArchived!(resource, true); + await entered.p; + controller.setChatSessionItemArchived!(resource, false); + await h.setEnabled(CloudSandboxEnabledSettingId, false); + await h.setEnabled(CloudSandboxEnabledSettingId, true); + await h.refresh(); + await pending.complete(); + await timeout(0); + assert.deepStrictEqual({ + mutations: h.calls.archivedTasks, notifications: h.errorNotifications, + replaced: h.controllers.get(sessionType) !== controller, archived: h.items()[0].archived, + }, { + mutations: [{ taskId: discovered.taskId, archived: true }], notifications: [], + replaced: true, archived: false, + }); + }); + test('trusts only discovered sandbox authorities and releases trust on teardown', async () => { const h = createHarness(store); await h.refresh(); @@ -550,7 +638,7 @@ suite('Editor cloud sandbox discovery', () => { isNew: h.resolvers.get(sessionType)?.isNew(resource), group: h.contributions.get(sessionType)?.sessionListGroup, }, { - items: [{ resource: resource.toString(), title: discovered.name, status: ChatSessionStatus.NeedsInput, archived: undefined, isRead: undefined }], + items: [{ resource: resource.toString(), title: discovered.name, status: ChatSessionStatus.NeedsInput, archived: false, isRead: undefined }], created: 0, connected: [], isNew: false, group: SessionType.CopilotCloud, }); }); @@ -1132,7 +1220,7 @@ suite('Editor cloud sandbox discovery', () => { }); }); - test('waits for authentication before adopting host read and archive flags', async () => { + test('waits for authentication before adopting host read flags and preserves task archive state', async () => { const h = createHarness(store); await h.refresh(); h.state.online = true; @@ -1144,12 +1232,12 @@ suite('Editor cloud sandbox discovery', () => { h.authenticationPending.set(false, undefined); await h.controllers.get(sessionType)!.refresh(CancellationToken.None); assert.deepStrictEqual({ before, after: h.items().map(item => [item.label, item.isRead, item.archived]) }, { - before: [[discovered.name, undefined, undefined]], - after: [['Host title', true, true]], + before: [[discovered.name, undefined, false]], + after: [['Host title', true, false]], }); }); - test('publishes unknown read and archive flags on disconnect without changing activity', async () => { + test('publishes unknown read flags on disconnect without changing task archive state or activity', async () => { const h = createHarness(store); await h.refresh(); h.state.online = true; @@ -1173,16 +1261,16 @@ suite('Editor cloud sandbox discovery', () => { })), items: controller.items.map(item => [item.resource.toString(), item.status, item.isRead, item.archived]), }, { - before: [[resource.toString(), ChatSessionStatus.NeedsInput, true, true]], + before: [[resource.toString(), ChatSessionStatus.NeedsInput, true, false]], deltas: [{ - items: [[resource.toString(), ChatSessionStatus.NeedsInput, undefined, undefined]], + items: [[resource.toString(), ChatSessionStatus.NeedsInput, undefined, false]], removed: undefined, }], - items: [[resource.toString(), ChatSessionStatus.NeedsInput, undefined, undefined]], + items: [[resource.toString(), ChatSessionStatus.NeedsInput, undefined, false]], }); }); - test('publishes host read and archive flags again when connection availability returns', async () => { + test('publishes host read flags again when connection availability returns without overriding task archive state', async () => { const h = createHarness(store); await h.refresh(); h.state.online = true; @@ -1199,7 +1287,7 @@ suite('Editor cloud sandbox discovery', () => { h.state.connected = true; h.connectionsChanged.fire(); - assert.deepStrictEqual(deltas.map(delta => delta.addedOrUpdated?.map(item => [item.isRead, item.archived])), [[[true, true]]]); + assert.deepStrictEqual(deltas.map(delta => delta.addedOrUpdated?.map(item => [item.isRead, item.archived])), [[[true, false]]]); }); test('falls back to original history after a failed live connection, without creating a replacement', async () => { From 7ca7edcbdb44ee860f7f4810c04b2cb2930364df Mon Sep 17 00:00:00 2001 From: roblourens Date: Sun, 4 Oct 2026 15:25:51 -0700 Subject: [PATCH 21/49] sessions: show Cloud Sandbox setup progress (#339604) * sessions: show Cloud Sandbox setup progress Reuse draft preparation progress for sandbox provisioning and repository cloning. Wait for repository readiness before dispatching the first turn and handing off to the remote session. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Merge main into Cloud Sandbox setup progress Preserve upstream sandbox renaming and startup progress regression coverage. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: harden Cloud Sandbox preparation lifecycle Keep provisioning protected until its connection attempt settles, reference-count overlapping preparation activities, and preserve clone retry progress. Localize preparation errors and add regression coverage for these cases. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meta/copilotd/cloudSandboxProjectMeta.ts | 4 + .../common/cloudSandboxProjectMeta.test.ts | 34 +++ .../COPILOT_CHAT_SESSIONS_PROVIDER.md | 2 +- .../browser/copilotChatSessionsProvider.ts | 119 ++++++---- .../copilotChatSessionsProvider.test.ts | 207 +++++++++++++++--- .../cloudSandboxAgentHostContribution.ts | 87 +++++++- .../cloudSandboxAgentHostContribution.test.ts | 189 +++++++++++++++- 7 files changed, 556 insertions(+), 86 deletions(-) create mode 100644 src/vs/platform/agentHost/test/common/cloudSandboxProjectMeta.test.ts diff --git a/src/vs/platform/agentHost/common/meta/copilotd/cloudSandboxProjectMeta.ts b/src/vs/platform/agentHost/common/meta/copilotd/cloudSandboxProjectMeta.ts index 9231c100531a..6fdf65b818c9 100644 --- a/src/vs/platform/agentHost/common/meta/copilotd/cloudSandboxProjectMeta.ts +++ b/src/vs/platform/agentHost/common/meta/copilotd/cloudSandboxProjectMeta.ts @@ -13,6 +13,7 @@ export interface ICloudSandboxProject { readonly path: string; readonly git: boolean; readonly status: 'ready' | 'cloning' | 'failed'; + readonly progress?: number; readonly remoteUrl?: string; readonly error?: string; } @@ -37,6 +38,9 @@ function readProject(value: unknown): ICloudSandboxProject | undefined { path: value.path, git: value.git, status, + progress: status === 'cloning' && typeof value.progress === 'number' && Number.isFinite(value.progress) + ? Math.min(100, Math.max(0, value.progress)) + : undefined, remoteUrl: typeof value.remoteUrl === 'string' ? value.remoteUrl : undefined, error: typeof value.error === 'string' ? value.error : undefined, }; diff --git a/src/vs/platform/agentHost/test/common/cloudSandboxProjectMeta.test.ts b/src/vs/platform/agentHost/test/common/cloudSandboxProjectMeta.test.ts new file mode 100644 index 000000000000..797399565aad --- /dev/null +++ b/src/vs/platform/agentHost/test/common/cloudSandboxProjectMeta.test.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { readCloudSandboxCloneResult, readCloudSandboxProjects } from '../../common/meta/cloudSandboxProjectMeta.js'; + +suite('CloudSandboxProjectMeta', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reads finite clone percentages, clamps their range, and ignores progress after cloning', () => { + const project = { id: 'checkout', path: '/checkout', git: true }; + assert.deepStrictEqual({ + cloning: [undefined, 0, 58, 100, -1, 101, NaN, Infinity, '58'].map(progress => + readCloudSandboxCloneResult({ project: { ...project, status: 'cloning', progress } })?.progress), + otherStates: [undefined, 'ready', 'failed'].map(status => + readCloudSandboxCloneResult({ project: { ...project, status, progress: 58 } })?.progress), + root: readCloudSandboxProjects({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { + schema: { type: 'object', properties: {} }, + values: { copilot: { projects: [{ ...project, status: 'cloning', progress: 58 }] } }, + }, + })?.map(project => ({ status: project.status, progress: project.progress })), + }, { + cloning: [undefined, 0, 58, 100, 0, 100, undefined, undefined, undefined], + otherStates: [undefined, undefined, undefined], + root: [{ status: 'cloning', progress: 58 }], + }); + }); +}); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md index b7dc8e5a4b58..bfd685d4ee98 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md @@ -54,7 +54,7 @@ The provider never opens chat UI directly. Presentation and focus remain owned b Each session has a single chat; the provider does not advertise multiple chats, rename, or delete. Follow-up turns go through the committed session's existing chat resource. -Sandbox creation reuses the remote draft and optimistic replacement lifecycle. Repository selection creates only a draft. The first send provisions through `CloudSandboxAgentHostContribution`, sends the prompt once into the provisioned session's existing main chat, and transfers ownership to that environment's provider. Until that handoff, the creation provider owns an extension-independent, read-only transcript. The creation provider does not supply the regular Cloud model catalog; the connected host owns model selection. Explicit sandbox drafts fail when sandbox creation is disabled rather than falling back to the server-run Cloud agent. If the first send fails after provisioning, the environment's session is published so it remains recoverable. +Sandbox creation reuses the remote draft and optimistic replacement lifecycle. Repository selection creates only a draft. The first send provisions through `CloudSandboxAgentHostContribution`, awaits the connection's advertised repository preparation, sends the prompt once into the provisioned session's existing main chat, and transfers ownership to that environment's provider. Until that handoff, the draft exposes shared `ISession.preparationProgress`; the chat view owns the transient, extension-independent preparation transcript. The creation provider does not supply the regular Cloud model catalog; the connected host owns model selection. Explicit sandbox drafts fail when sandbox creation is disabled rather than falling back to the server-run Cloud agent. If the first send fails after provisioning, the environment's session is published so it remains recoverable. ## Picker contributions diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 8bfca530dfca..5c2d1bfdabcf 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -8,7 +8,7 @@ import { raceCancellationError, raceTimeout } from '../../../../../base/common/a import { Codicon } from '../../../../../base/common/codicons.js'; import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../../../base/common/htmlContent.js'; -import { Disposable, DisposableStore, IDisposable, DisposableMap, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, DisposableMap, IReference, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { deepClone } from '../../../../../base/common/objects.js'; import { isWeb } from '../../../../../base/common/platform.js'; @@ -28,7 +28,7 @@ import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { assertAutomationSessionTemplate, IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { AutomationModelConfiguration } from '../../../automations/browser/automationModelConfiguration.js'; -import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, IGitHubIssueRef, ISessionArtifact, SessionArtifactKind, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement, ISessionChangesSummary, ISessionCreationReference } from '../../../../services/sessions/common/session.js'; +import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, IGitHubIssueRef, ISessionArtifact, SessionArtifactKind, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement, ISessionChangesSummary, ISessionCreationReference, ISessionPreparationProgress } from '../../../../services/sessions/common/session.js'; import { linkKey } from '../../../../common/sessionLinks.js'; import { getSessionApplication } from '../../../../common/sessionApplication.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; @@ -48,6 +48,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; @@ -65,7 +66,6 @@ import { resolveGitRepositoryFromGitConfig } from '../../../../services/sessions import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; import { RepositoryPicker } from '../../../../../workbench/contrib/chat/browser/agentSessions/repositoryPicker.js'; import { ChatAIDisabledSettingId } from '../../../../../platform/chat/common/chatSettings.js'; -import { ReadOnlyChatSession } from '../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxReadOnlySessionHandler.js'; /** Copilot Cloud session type - cloud-hosted agent. */ export const CopilotCloudSessionType: ISessionType = { @@ -133,6 +133,8 @@ export interface ICopilotChatSession { readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; /** Whether the session is still initializing. */ readonly loading: IObservable; + readonly preparationProgress?: IObservable; + readonly isNewSessionRequestInProgress?: IObservable; /** Whether the session is archived. */ readonly isArchived: IObservable; /** Whether the session has been read. */ @@ -294,6 +296,10 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = observableValue(this, undefined); readonly loading: IObservable = observableValue(this, false); + readonly preparationProgress = observableValue(this, undefined); + readonly isNewSessionRequestInProgress = this.preparationProgress.map(progress => !!progress); + private readonly _preparationCts = this._register(new MutableDisposable()); + private _preparationActivities = 0; private readonly _isArchived = observableValue(this, false); readonly isArchived: IObservable = this._isArchived; @@ -413,6 +419,33 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession this._status.set(status, undefined); } + startPreparation(): IReference { + if (!this._preparationCts.value) { + this._preparationCts.value = new CancellationTokenSource(this.lifetimeToken); + this.reportPreparationProgress(localize('sandbox.provisioningContainer', "Setting up cloud container")); + } + this._preparationActivities++; + const token = this._preparationCts.value.token; + const activity = toDisposable(() => { + if (--this._preparationActivities === 0) { + this.clearPreparation(); + } + }); + return { object: token, dispose: () => activity.dispose() }; + } + + reportPreparationProgress(message: string): void { + const source = this._preparationCts.value; + if (source && !source.token.isCancellationRequested && this.preparationProgress.get()?.message !== message) { + this.preparationProgress.set({ message, cancel: () => source.cancel() }, undefined); + } + } + + private clearPreparation(): void { + this.preparationProgress.set(undefined, undefined); + this._preparationCts.clear(); + } + setArchived(archived: boolean): void { this._isArchived.set(archived, undefined); } @@ -1080,8 +1113,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * cannot transiently drop them. */ private readonly _inFlightCommits = new Set(); - private readonly _sandboxSends = new Map(); - private readonly _sandboxCreationChats = this._register(new DisposableMap()); + private readonly _sandboxSends = new Set(); private readonly _repositoryPicker = this._register(new MutableDisposable()); /** Cache of ISession wrappers, keyed by session ID. */ @@ -1117,30 +1149,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions super(); this._loadCreatedBySessions(); - if (providerMode === 'sandbox') { - this._register(this.chatSessionsService.registerChatSessionContentProvider(CopilotSandboxSessionType.id, { - provideChatSessionContent: async resource => { - const sessionId = toSessionId(this.id, resource); - const options = this._sandboxSends.get(sessionId); - if (!options) { - throw new Error(localize('sandbox.draftNotFound', "The GitHub sandbox draft is no longer available.")); - } - const chat = new ReadOnlyChatSession(resource, [{ - type: 'request', - prompt: options.query, - participant: CopilotSandboxSessionType.id, - variableData: { variables: options.attachedContext ?? [] }, - isHidden: options.hideFromTranscript, - }, { - type: 'response', - participant: CopilotSandboxSessionType.id, - parts: [{ kind: 'markdownContent', content: new MarkdownString(localize('sandbox.starting', "Starting GitHub sandbox...")) }], - }], undefined, constObservable(true)); - this._sandboxCreationChats.set(sessionId, chat); - return chat; - }, - })); - } this._register(Event.filter( this.configurationService.onDidChangeConfiguration, @@ -1659,12 +1667,24 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions throw new Error(`Session '${sessionId}' does not support side chats`); } + startNewSessionRequest(sessionId: string): IDisposable | undefined { + const session = this._newSessions.get(sessionId); + if (!session || !this._usesSandbox(session)) { + return undefined; + } + return session.startPreparation(); + } + + private _usesSandbox(session: RemoteNewSession): boolean { + return this.providerMode === 'sandbox' || !!session.useSandbox.get() && !!session.repoNwo && isCloudSandboxEnabled(this.configurationService); + } + async createNewChat(sessionId: string, _prompt?: string): Promise { const session = this._newSessions.get(sessionId); if (!session) { throw new Error(`[CopilotChatSessionsProvider] Session '${sessionId}' does not support multiple chats`); } - if (this.providerMode !== 'sandbox') { + if (!this._usesSandbox(session)) { (await this._createChatSession(session.resource, session)).dispose(); } const newChat = this._withChangesets(buildChatFromSession(session), session.workspace); @@ -1673,7 +1693,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } /** Test seam: the contribution registry is global, so tests override this with a stub. */ - protected _getCloudSandboxContribution(): Pick { + protected _getCloudSandboxContribution(): Pick { return getWorkbenchContribution(CloudSandboxAgentHostContribution.ID); } @@ -1693,6 +1713,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * first turn has to be dispatched here rather than being picked up server-side. */ private async _sendFirstChatToSandbox(session: RemoteNewSession, repoNwo: string, options: ISendRequestOptions): Promise { + this._sandboxSends.add(session.sessionId); + const preparation = session.startPreparation(); + const token = preparation.object; session.setTitle((options.title || options.query.split('\n')[0]).substring(0, 100) || localize('new session', "New Session")); session.setStatus(SessionStatus.InProgress); this._sessionCache.set(session.resource.toString(), session); @@ -1703,21 +1726,34 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Read before provisioning: the composer session is retired below, and its selection is the // only record of what the user picked for this turn. const selectedModel = this.providerMode === 'sandbox' ? undefined : this._selectedCloudModel(session); - const token = this.providerMode === 'sandbox' ? session.lifetimeToken : CancellationToken.None; + const store = new DisposableStore(); + store.add(preparation); + store.add(token.onCancellationRequested(() => store.dispose())); + const progress: IProgress = { + report: message => { + if (!store.isDisposed) { + session.reportPreparationProgress(message); + } + }, + }; try { - provisioned = await this._getCloudSandboxContribution().provisionSession({ + const contribution = this._getCloudSandboxContribution(); + provisioned = await raceCancellationError(contribution.provisionSession({ repoNwo, // No `baseRef`: cloud sessions have no branch picker; Mission Control chooses. prompt: options.query, - }, token); + }, token, progress), token); if (token.isCancellationRequested) { throw new CancellationError(); } + store.add(contribution.trackSessionCreationProgress(provisioned.environmentId, repoNwo, progress)); + await raceCancellationError(contribution.prepareSession(provisioned.environmentId, repoNwo, token), token); + progress.report(localize('sandbox.startingAgent', "Starting Copilot agent")); // Send into the session's main chat rather than `createNewChat`, which would mint an // *additional* peer chat inside a session that already has one. const chat = provisioned.session.mainChat.get(); - await this._carryModelToSandbox(provisioned, chat.resource, selectedModel); + await raceCancellationError(this._carryModelToSandbox(provisioned, chat.resource, selectedModel), token); const committed = await provisioned.provider.sendRequest(provisioned.session.sessionId, chat.resource, options); // Retire only once the turn is dispatched; swapping earlier bounces the view home. @@ -1736,6 +1772,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._onDidChangeSessions.fire({ added: [], removed: [placeholder], changed: [] }); session.dispose(); throw error; + } finally { + store.dispose(); + this._sandboxSends.delete(session.sessionId); } } @@ -1855,13 +1894,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions if (!newSession.repoNwo || !isCloudSandboxEnabled(this.configurationService) || this.configurationService.getValue(ChatAIDisabledSettingId)) { throw new Error(localize('sandbox.unavailable', "GitHub sandbox creation is no longer available. Enable the feature and choose a repository to try again.")); } - this._sandboxSends.set(sessionId, options); - try { - return await this._sendFirstChatToSandbox(newSession, newSession.repoNwo, options); - } finally { - this._sandboxSends.delete(sessionId); - this._sandboxCreationChats.deleteAndDispose(sessionId); - } + return this._sendFirstChatToSandbox(newSession, newSession.repoNwo, options); } // `useSandbox` is persisted, so it can outlive the setting being turned off. Re-check // rather than trust it: falling back to the cloud agent beats a send that must fail. @@ -2528,6 +2561,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions modelId: chat.modelId, mode: chat.mode, loading: chat.loading, + preparationProgress: chat.preparationProgress, + isNewSessionRequestInProgress: chat.isNewSessionRequestInProgress, isArchived: chat.isArchived, isRead: chat.isRead, description: chat.description, diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 9108204c9e9b..ab69124b2954 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -49,6 +49,7 @@ import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID, CopilotCloudSessionTy import { ChatAIDisabledSettingId } from '../../../../../../platform/chat/common/chatSettings.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IProgress } from '../../../../../../platform/progress/common/progress.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IPathService } from '../../../../../../workbench/services/path/common/pathService.js'; import { MockLabelService } from '../../../../../../workbench/services/label/test/common/mockLabelService.js'; @@ -376,12 +377,12 @@ function createProviderWithConfig( * workbench contribution registry. */ class TestSandboxCopilotProvider extends CopilotChatSessionsProvider { - sandboxContribution: Pick | undefined; + sandboxContribution: Pick | undefined; /** Only the timeout test lowers this; the rest keep the real budget so they cannot race it. */ sandboxModelWaitMs: number | undefined; - protected override _getCloudSandboxContribution(): Pick { + protected override _getCloudSandboxContribution(): Pick { if (!this.sandboxContribution) { throw new Error('No cloud sandbox contribution was registered'); } @@ -634,6 +635,7 @@ suite('CopilotChatSessionsProvider', () => { })), }); }); + } test('cancelling repository selection creates no workspace or session in either mode', async () => { @@ -2527,7 +2529,7 @@ suite('CopilotChatSessionsProvider', () => { // `repoNwo` has to strip back down to `owner/repo`. const repoWorkspace = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/osortega/simple-server/HEAD' }); - function createSandboxProvider(opts: { enabled?: boolean; provision?: () => Promise; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; providerMode?: 'default' | 'sandbox'; onGetChatSession?: () => void } = {}) { + function createSandboxProvider(opts: { enabled?: boolean; provision?: CloudSandboxAgentHostContribution['provisionSession']; prepare?: CloudSandboxAgentHostContribution['prepareSession']; trackProgress?: CloudSandboxAgentHostContribution['trackSessionCreationProgress']; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; providerMode?: 'default' | 'sandbox'; onGetChatSession?: () => void } = {}) { const configurationService = new TestConfigurationService(); configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, opts.enabled ?? true); configurationService.setUserConfiguration(RemoteAgentHostsEnabledSettingId, true); @@ -2543,13 +2545,15 @@ suite('CopilotChatSessionsProvider', () => { const provisionRequests: ICloudSandboxCreateSessionRequest[] = []; provider.sandboxContribution = { - provisionSession: async request => { + provisionSession: async (request, token, progress) => { provisionRequests.push(request); if (opts.provision) { - return opts.provision(); + return opts.provision(request, token, progress); } throw new Error('provisioning failed'); }, + prepareSession: opts.prepare ?? (async () => { }), + trackSessionCreationProgress: opts.trackProgress ?? (() => toDisposable(() => { })), }; return { provider, provisionRequests, cloudSends, notifications, configurationService, chatContentProviders }; } @@ -2625,6 +2629,153 @@ suite('CopilotChatSessionsProvider', () => { }]; } + for (const providerMode of ['default', 'sandbox'] as const) { + test(`${providerMode} sandbox startup uses draft preparation progress without registering a chat provider or changing its resource`, async () => { + const pending = new DeferredPromise(); + const preparing = new DeferredPromise(); + const tracking = new DeferredPromise(); + const sending = new DeferredPromise(); + const dispatched = new DeferredPromise(); + let progress: IProgress | undefined; + let released = false; + const provisioned = provisionedSession(async () => { + await sending.complete(); + return dispatched.p; + }); + const { provider, chatContentProviders } = createSandboxProvider({ + providerMode, + provision: (_request, _token, progress) => { + progress?.report('Connecting to cloud container'); + return pending.p; + }, + prepare: () => preparing.p, + trackProgress: (_environmentId, _repoNwo, reporter) => { + progress = reporter; + progress.report('Cloning repository (0%)'); + void tracking.complete(); + return toDisposable(() => { released = true; }); + }, + }); + const draft = provider.createNewSession(repoWorkspace, providerMode === 'sandbox' ? CopilotSandboxSessionType.id : CopilotCloudSessionType.id); + provider.getSession(draft.sessionId)!.setUseSandbox(true); + const activity = disposables.add(provider.startNewSessionRequest(draft.sessionId)!); + const initial = draft.preparationProgress?.get()?.message; + const before = draft.mainChat.get().resource; + await provider.createNewChat(draft.sessionId); + const updates: (string | undefined)[] = []; + disposables.add(autorun(reader => updates.push(draft.preparationProgress?.read(reader)?.message))); + const request = provider.sendRequest(draft.sessionId, draft.mainChat.get().resource, { query: 'fix it' }); + const resource = draft.mainChat.get().resource; + await pending.complete(provisioned); + await tracking.p; + progress!.report('Cloning repository (58%)'); + const current = provider.getSessions()[0].preparationProgress?.get()?.message; + await preparing.complete(); + await sending.p; + await dispatched.complete(upcastPartial({ sessionId: 'committed' })); + await request; + activity.dispose(); + progress!.report('Late progress'); + + assert.deepStrictEqual({ + initial, + updates, + current, + sameResource: extUri.isEqual(resource, before), + contentProviders: chatContentProviders.length, + released, + final: draft.preparationProgress?.get(), + active: draft.isNewSessionRequestInProgress?.get(), + }, { + initial: 'Setting up cloud container', + updates: ['Setting up cloud container', 'Connecting to cloud container', 'Cloning repository (0%)', 'Cloning repository (58%)', 'Starting Copilot agent', undefined], + current: 'Cloning repository (58%)', + sameResource: true, + contentProviders: 0, + released: true, + final: undefined, + active: false, + }); + }); + + test(`${providerMode} sandbox preparation can be canceled from the existing progress surface`, async () => { + const pending = new DeferredPromise(); + const { provider } = createSandboxProvider({ providerMode, provision: () => pending.p }); + const draft = provider.createNewSession(repoWorkspace, providerMode === 'sandbox' ? CopilotSandboxSessionType.id : CopilotCloudSessionType.id); + provider.getSession(draft.sessionId)!.setUseSandbox(true); + const activity = disposables.add(provider.startNewSessionRequest(draft.sessionId)!); + const request = provider.sendRequest(draft.sessionId, draft.mainChat.get().resource, { query: 'fix it' }); + const rejected = assert.rejects(request, /Canceled/); + draft.preparationProgress?.get()?.cancel(); + await rejected; + await pending.complete(provisionedSession()); + activity.dispose(); + assert.deepStrictEqual({ + progress: draft.preparationProgress?.get(), + active: draft.isNewSessionRequestInProgress?.get(), + listed: provider.getSessions(), + }, { progress: undefined, active: false, listed: [] }); + }); + + test(`${providerMode} rejected overlapping request does not clear the active sandbox preparation`, async () => { + const pending = new DeferredPromise(); + let token: CancellationToken | undefined; + let progress: IProgress | undefined; + const { provider } = createSandboxProvider({ + providerMode, + provision: (_request, requestToken, reporter) => { + token = requestToken; + progress = reporter; + return pending.p; + }, + }); + const draft = provider.createNewSession(repoWorkspace, providerMode === 'sandbox' ? CopilotSandboxSessionType.id : CopilotCloudSessionType.id); + provider.getSession(draft.sessionId)!.setUseSandbox(true); + const firstActivity = disposables.add(provider.startNewSessionRequest(draft.sessionId)!); + const request = provider.sendRequest(draft.sessionId, draft.mainChat.get().resource, { query: 'fix it' }); + const secondActivity = disposables.add(provider.startNewSessionRequest(draft.sessionId)!); + await assert.rejects(provider.sendRequest(draft.sessionId, draft.mainChat.get().resource, { query: 'duplicate' }), /already being started/); + secondActivity.dispose(); + secondActivity.dispose(); + progress?.report('Connecting to cloud container'); + const afterRejectedRequest = { + message: draft.preparationProgress?.get()?.message, + active: draft.isNewSessionRequestInProgress?.get(), + cancelled: token?.isCancellationRequested, + }; + firstActivity.dispose(); + const stillSending = draft.isNewSessionRequestInProgress?.get(); + await pending.complete(provisionedSession()); + await request; + assert.deepStrictEqual({ + afterRejectedRequest, + stillSending, + progress: draft.preparationProgress?.get(), + active: draft.isNewSessionRequestInProgress?.get(), + }, { + afterRejectedRequest: { message: 'Connecting to cloud container', active: true, cancelled: false }, + stillSending: true, + progress: undefined, + active: false, + }); + }); + } + + test('does not start sandbox preparation for a normal Cloud session or a disabled sandbox preference', () => { + const results = []; + for (const enabled of [true, false]) { + const { provider } = createSandboxProvider({ enabled }); + const draft = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + provider.getSession(draft.sessionId)!.setUseSandbox(!enabled); + results.push({ + activity: provider.startNewSessionRequest(draft.sessionId), + progress: draft.preparationProgress?.get(), + active: draft.isNewSessionRequestInProgress?.get(), + }); + } + assert.deepStrictEqual(results, [{ activity: undefined, progress: undefined, active: false }, { activity: undefined, progress: undefined, active: false }]); + }); + suite('explicit browser sandbox creation', () => { test('creates a repository-only draft without allocating or loading an extension chat', async () => { model.addSession(createMockAgentSession(URI.parse('copilot-cloud-agent:/existing-cloud'), { providerType: AgentSessionProviders.Cloud })); @@ -2673,7 +2824,7 @@ suite('CopilotChatSessionsProvider', () => { sent.push({ sessionId, resource: resource.toString(), query: options.query }); return upcastPartial({ sessionId, resource }); }); - const { provider, provisionRequests, cloudSends, chatContentProviders } = createSandboxProvider({ + const { provider, provisionRequests, cloudSends } = createSandboxProvider({ providerMode: 'sandbox', provision: () => pending.p, getOptionGroups: () => cloudModelOptionGroup('cloud-only-model', 'cloud-only-model'), @@ -2686,15 +2837,7 @@ suite('CopilotChatSessionsProvider', () => { disposables.add(provider.onDidReplaceSession(event => replacements.push(event.to.sessionId))); const request = provider.sendRequest(draft.sessionId, draft.resource, { query: 'fix it' }); const repeatedRequest = assert.rejects(provider.sendRequest(draft.sessionId, draft.resource, { query: 'another prompt' }), /already being started/); - const content = await chatContentProviders[0].provideChatSessionContent(draft.resource, CancellationToken.None); - const startup = { - resource: extUri.isEqual(content.sessionResource, draft.resource), - prompt: content.history[0].type === 'request' ? content.history[0].prompt : undefined, - progress: content.history[1].type === 'response' ? content.history[1].parts.map(part => part.kind === 'markdownContent' ? part.content.value : part.kind) : [], - readOnly: content.isReadOnly?.get(), - }; - let contentDisposed = false; - disposables.add(Event.once(content.onWillDispose)(() => contentDisposed = true)); + const startup = draft.preparationProgress?.get()?.message; await pending.complete(provisioned); const committed = await request; await repeatedRequest; @@ -2709,7 +2852,7 @@ suite('CopilotChatSessionsProvider', () => { committed: committed.sessionId, placeholders: provider.getSessions(), startup, - contentDisposed, + progressCleared: draft.preparationProgress?.get(), }, { provisionRequests: [{ repoNwo: 'osortega/simple-server', prompt: 'fix it' }], sent: [{ sessionId: 'agenthost:sess-new', resource: 'agent-host-copilot:/sess-new', query: 'fix it' }], @@ -2719,28 +2862,27 @@ suite('CopilotChatSessionsProvider', () => { replacements: ['agenthost:sess-new'], committed: 'agenthost:sess-new', placeholders: [], - startup: { resource: true, prompt: 'fix it', progress: ['Starting GitHub sandbox...'], readOnly: true }, - contentDisposed: true, + startup: 'Setting up cloud container', + progressCleared: undefined, }); }); - test('reopens the provisional transcript and retires it when provisioning fails', async () => { + test('clears draft preparation progress when provisioning fails', async () => { const pending = new DeferredPromise(); const { provider, chatContentProviders } = createSandboxProvider({ providerMode: 'sandbox', provision: () => pending.p }); const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); const request = provider.sendRequest(draft.sessionId, draft.resource, { query: 'fix it' }); const rejected = assert.rejects(request, /provisioning failed/); - const contentProvider = chatContentProviders[0]; - const first = await contentProvider.provideChatSessionContent(draft.resource, CancellationToken.None); - first.dispose(); - const reopened = await contentProvider.provideChatSessionContent(draft.resource, CancellationToken.None); - let disposed = false; - disposables.add(Event.once(reopened.onWillDispose)(() => disposed = true)); + const initial = draft.preparationProgress?.get()?.message; await pending.error(new Error('provisioning failed')); await rejected; - assert.deepStrictEqual({ recreated: first !== reopened, history: reopened.history, disposed }, { recreated: true, history: first.history, disposed: true }); - await assert.rejects(contentProvider.provideChatSessionContent(draft.resource, CancellationToken.None), /no longer available/); + assert.deepStrictEqual({ + initial, + progress: draft.preparationProgress?.get(), + active: draft.isNewSessionRequestInProgress?.get(), + contentProviders: chatContentProviders.length, + }, { initial: 'Setting up cloud container', progress: undefined, active: false, contentProviders: 0 }); }); for (const [setting, disabledValue] of [[CloudSandboxEnabledSettingId, false], [RemoteAgentHostsEnabledSettingId, false], [ChatAIDisabledSettingId, true]] as const) { @@ -2761,10 +2903,15 @@ suite('CopilotChatSessionsProvider', () => { test('preserves a provisioned session after the first send fails', async () => { const provisioned = provisionedSession(async () => { throw new Error('send failed'); }); - const { provider, cloudSends } = createSandboxProvider({ providerMode: 'sandbox', provision: async () => provisioned }); + let released = false; + const { provider, cloudSends } = createSandboxProvider({ + providerMode: 'sandbox', + provision: async () => provisioned, + trackProgress: () => toDisposable(() => { released = true; }), + }); const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); await assert.rejects(provider.sendRequest(draft.sessionId, draft.resource, { query: 'fix it' }), /send failed/); - assert.deepStrictEqual({ published: provisioned.published, placeholders: provider.getSessions(), cloudSends }, { published: ['sess-new'], placeholders: [], cloudSends: [] }); + assert.deepStrictEqual({ published: provisioned.published, placeholders: provider.getSessions(), cloudSends, released }, { published: ['sess-new'], placeholders: [], cloudSends: [], released: true }); }); test('cleans up the placeholder after provisioning fails', async () => { @@ -2788,7 +2935,7 @@ suite('CopilotChatSessionsProvider', () => { const rejected = assert.rejects(request, /Canceled/); await pending.complete(provisioned); await rejected; - assert.deepStrictEqual({ sends, published: provisioned.published, placeholders: provider.getSessions() }, { sends: 0, published: ['sess-new'], placeholders: [] }); + assert.deepStrictEqual({ sends, placeholders: provider.getSessions(), progress: draft.preparationProgress?.get() }, { sends: 0, placeholders: [], progress: undefined }); }); }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts index dc1988b6cd61..27ce48ea0a57 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts @@ -4,19 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { raceCancellationError } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { CancellationError } from '../../../../../base/common/errors.js'; -import { DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; import { isWeb } from '../../../../../base/common/platform.js'; +import { equalsIgnoreCase } from '../../../../../base/common/strings.js'; +import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { AgentSession } from '../../../../../platform/agentHost/common/agent.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { CLOUD_SANDBOX_AGENT_PROVIDER, CLOUD_SANDBOX_SESSION_SCHEME, CloudSandboxAuthenticationRequiredError, cloudSandboxAddress, ICloudSandboxAgentHostService, ICloudSandboxApiService, ICloudSandboxCreatedSession, ICloudSandboxCreateSessionRequest } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { readCloudSandboxProjects } from '../../../../../platform/agentHost/common/meta/cloudSandboxProjectMeta.js'; import { IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -24,6 +30,7 @@ import { CloudSandboxSessionContribution, discoveredSessionProject, ICloudSandbo import { IRemoteAgentHostConnectionCustomizationService } from '../../../../../workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.js'; import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; +import { getGitHubRepositoryFromRemoteUrl } from '../../../../../workbench/contrib/git/common/utils.js'; import { IAgentHostConnectionLabels, IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; import { IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; @@ -66,8 +73,8 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri constructor( @ICloudSandboxAgentHostService cloudSandboxService: ICloudSandboxAgentHostService, @ICloudSandboxApiService apiService: ICloudSandboxApiService, - @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, - @IRemoteAgentHostConnectionCustomizationService connectionCustomizations: IRemoteAgentHostConnectionCustomizationService, + @IRemoteAgentHostService private readonly _remoteService: IRemoteAgentHostService, + @IRemoteAgentHostConnectionCustomizationService private readonly _sandboxConnectionCustomizations: IRemoteAgentHostConnectionCustomizationService, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, @IConfigurationService configurationService: IConfigurationService, @@ -80,7 +87,7 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri @IStorageService storageService: IStorageService, @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { - super(cloudSandboxService, apiService, remoteAgentHostService, connectionCustomizations, configurationService, instantiationService, chatSessionsService, logService, chatEntitlementService, hostService, storageService, workspaceTrustManagementService); + super(cloudSandboxService, apiService, _remoteService, _sandboxConnectionCustomizations, configurationService, instantiationService, chatSessionsService, logService, chatEntitlementService, hostService, storageService, workspaceTrustManagementService); this._updateRegistration(); this._register(this._agentHostFilterService.registerDiscoveryHandler(() => this._discoverAndSeed())); this._register(this._agentHostFilterService.onDidChange(() => { @@ -90,6 +97,24 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri })); } + async prepareSession(environmentId: string, repoNwo: string, token: CancellationToken): Promise { + const address = cloudSandboxAddress(environmentId); + const connection = this._remoteService.getConnection(address); + if (!connection) { + throw new Error(localize('sandbox.connectionUnavailable', "The cloud sandbox connection is no longer available.")); + } + const store = new DisposableStore(); + try { + const prepare = this._sandboxConnectionCustomizations.get(address)?.createSessionPreparation?.(connection, store); + if (!prepare) { + throw new Error(localize('sandbox.preparationUnavailable', "Cloud sandbox repository preparation is not registered.")); + } + await prepare(URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${repoNwo}` }), token); + } finally { + store.dispose(); + } + } + protected override _updateRegistration(): void { if (!this._isEnabled()) { this._hostGroupRegistration.clear(); @@ -138,7 +163,7 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri return this._instantiationService.createInstance(CloudSandboxSessionsProvider, config); } - async provisionSession(request: ICloudSandboxCreateSessionRequest, token: CancellationToken): Promise { + async provisionSession(request: ICloudSandboxCreateSessionRequest, token: CancellationToken, progress?: IProgress): Promise { if (!this._isEnabled()) { throw new Error('Copilot cloud sandbox connections are not enabled.'); } @@ -151,6 +176,7 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri } this._restoreAccount(accountKey); const enabledToken = this._enabledCts.token; + progress?.report(localize('sandbox.provisioningContainer', "Setting up cloud container")); const created = await this._apiService.createSession(request, token); const name = request.repoNwo ?? created.taskId; const address = cloudSandboxAddress(created.environmentId); @@ -159,6 +185,7 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri } this._provisioning.add(address); let seededProvider: CloudSandboxSessionsProvider | undefined; + let connectionAttempt: Promise | undefined; try { const now = Date.now(); this._ensureProvider({ ...created, name, repoName: request.repoNwo, updatedAt: new Date(now).toISOString() }); @@ -176,8 +203,10 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri }); seededProvider = provider; this._persistInventory(); - await this.connect({ environmentId: created.environmentId, sessionId: created.sessionId, name, connectionSource: 'created' }); - if (!this._isEnabled() || this._providerInstances.get(address) !== provider) { + progress?.report(localize('sandbox.connectingContainer', "Connecting to cloud container")); + connectionAttempt = this.connect({ environmentId: created.environmentId, sessionId: created.sessionId, name, connectionSource: 'created' }); + await raceCancellationError(connectionAttempt, token); + if (token.isCancellationRequested || !this._isEnabled() || this._providerInstances.get(address) !== provider) { throw new CancellationError(); } const session = provider.getCachedSession(created.sessionId); @@ -192,7 +221,49 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri } throw error; } finally { - this._provisioning.delete(address); + const releaseProvisioning = () => { this._provisioning.delete(address); }; + if (connectionAttempt) { + // A canceled caller must not let discovery tear down a connection that is still waking. + void connectionAttempt.then(releaseProvisioning, releaseProvisioning); + } else { + releaseProvisioning(); + } } } + + /** Observe repository setup until the caller finishes dispatching the first turn. */ + trackSessionCreationProgress(environmentId: string, repoNwo: string, progress: IProgress): IDisposable { + const store = new DisposableStore(); + const connection = this._remoteService.getConnection(cloudSandboxAddress(environmentId)); + if (!connection) { + return store; + } + let cloningProjectId: string | undefined; + const update = () => { + const state = connection.rootState.value; + if (!state || state instanceof Error) { + return; + } + const projects = readCloudSandboxProjects(state)?.filter(project => { + const remote = project.remoteUrl && getGitHubRepositoryFromRemoteUrl(project.remoteUrl, ['github.com']); + return remote && equalsIgnoreCase(`${remote.owner}/${remote.repo}`, repoNwo); + }); + const project = projects?.find(project => project.status === 'ready') ?? projects?.find(project => project.status === 'cloning') ?? projects?.[0]; + if (project?.status === 'cloning') { + cloningProjectId = project.id; + progress.report(project.progress === undefined + ? localize('sandbox.cloningRepository', "Cloning repository") + : localize('sandbox.cloningRepositoryProgress', "Cloning repository ({0}%)", Math.round(project.progress))); + } else if (project?.status === 'ready') { + progress.report(localize('sandbox.startingAgent', "Starting Copilot agent")); + store.dispose(); + } else if (project?.status === 'failed' && project.id === cloningProjectId) { + progress.report(localize('sandbox.cloningFailed', "Repository cloning failed")); + store.dispose(); + } + }; + store.add(connection.rootState.onDidChange(update)); + update(); + return store; + } } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts index 434b8d09ee47..be36fe84a1fa 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts @@ -5,8 +5,8 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { CancellationError } from '../../../../../../base/common/errors.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { CancellationError, isCancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { isWeb } from '../../../../../../base/common/platform.js'; @@ -16,7 +16,7 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/virtualSc import { URI } from '../../../../../../base/common/uri.js'; import { StorageValue } from '../../../../../../base/parts/storage/common/storage.js'; import { AgentSession } from '../../../../../../platform/agentHost/common/agent.js'; -import { IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentConnection, IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; import { agentHostAuthority, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { remoteAgentHostSessionTypeId } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; import { IReplayedTaskHistory } from '../../../../../../platform/agentHost/common/taskEventReplay.js'; @@ -36,6 +36,8 @@ import { } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { SessionStatus } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { RootStateSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { RootState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { constObservable, IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ChatAIDisabledSettingId } from '../../../../../../platform/chat/common/chatSettings.js'; @@ -56,6 +58,7 @@ import { ISessionsProvider } from '../../../../../services/sessions/common/sessi import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { CLOUD_SANDBOX_CREATION_PROVIDER_ID, CloudSandboxAgentHostContribution } from '../../browser/cloudSandboxAgentHostContribution.js'; import { IRemoteAgentHostConnectionCustomizationService } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.js'; +import { createCloudSandboxSessionPreparation } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxLegacySessionPreparation.js'; import { ICloudSandboxSessionEnvironment } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.js'; import { IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; import { CloudSandboxSessionsProvider } from '../../browser/cloudSandboxSessionsProvider.js'; @@ -277,6 +280,7 @@ async function createContribution(store: Pick, sessions: readonly storageService?: IStorageService; readonly accountKey?: string | null; readonly waitForDiscovery?: boolean; + readonly connection?: IAgentConnection; }): Promise { const discoveryHandlers: (() => Promise)[] = []; const hostGroups: IAgentHostGroup[] = []; @@ -401,11 +405,18 @@ async function createContribution(store: Pick, sessions: override readonly connections = []; // No live protocol client is modelled, so activation stops once the connect has been made // rather than going on to wait for the host to advertise its agents. - override getConnection() { return undefined; } + override getConnection(address: string) { return address === cloudSandboxAddress('env-new') ? options?.connection : undefined; } override async removeRemoteAgentHost(): Promise { } }()); instantiationService.stub(IRemoteAgentHostConnectionCustomizationService, new class extends mock() { override register(): IDisposable { return toDisposable(() => { }); } + override get() { + return { + createSessionPreparation: (connection: IAgentConnection, owner: DisposableStore) => createCloudSandboxSessionPreparation(connection.rootState, async () => { + throw new Error('Unexpected clone request'); + }, owner), + }; + } }()); instantiationService.stub(ISessionsProvidersService, store.add(new StubSessionsProvidersService()) as unknown as ISessionsProvidersService); instantiationService.stub(IAgentHostFilterService, new class extends mock() { @@ -1832,8 +1843,9 @@ suite('CloudSandboxAgentHostContribution provisioning', () => { const harness = await createContribution(store, []); let connectionSource: ICloudSandboxConnectOptions['connectionSource']; harness.onConnect = async options => { connectionSource = options.connectionSource; }; + const progress: string[] = []; - const provisioned = await harness.contribution.provisionSession({ repoNwo: 'osortega/simple-server', prompt: 'fix it' }, CancellationToken.None); + const provisioned = await harness.contribution.provisionSession({ repoNwo: 'osortega/simple-server', prompt: 'fix it' }, CancellationToken.None, { report: message => progress.push(message) }); const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-new')); assert.deepStrictEqual({ @@ -1844,15 +1856,138 @@ suite('CloudSandboxAgentHostContribution provisioning', () => { connectedTo: harness.connectedTo, connectionSource, resolvedSession: provisioned.session.resource.path, + progress, }, { ids: { taskId: 'task-new', sessionId: 'sess-new', environmentId: 'env-new' }, seeded: [{ session: 'copilot:/sess-new', summary: 'osortega/simple-server', project: 'osortega/simple-server' }], connectedTo: ['env-new'], connectionSource: 'created', resolvedSession: '/sess-new', + progress: ['Setting up cloud container', 'Connecting to cloud container'], }); }); + test('tracks initial and live clone percentages only for the selected repository', async () => { + const root = store.add(new RootStateSubscription('test', () => { })); + let sequence = 0; + const update = (status: string, progress?: number) => root.handleSnapshot({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { + schema: { type: 'object', properties: {} }, + values: { + copilot: { + projects: [ + { id: 'other', path: '/other', git: true, status: 'ready', remoteUrl: 'https://github.com/other/repo' }, + { id: 'checkout', path: '/checkout', git: true, status, progress, remoteUrl: 'git@github.com:Microsoft/VSCode.git' }, + ] + } + }, + }, + }, sequence++); + update('cloning', 0); + const harness = await createContribution(store, [], { connection: upcastPartial({ rootState: root }) }); + const messages: string[] = []; + store.add(harness.contribution.trackSessionCreationProgress('env-new', 'microsoft/vscode', { report: message => messages.push(message) })); + update('cloning', 58); + update('ready'); + update('cloning', 1); + + assert.deepStrictEqual(messages, ['Cloning repository (0%)', 'Cloning repository (58%)', 'Starting Copilot agent']); + }); + + test('reuses repository preparation and waits for the host to report the clone ready', async () => { + const root = store.add(new RootStateSubscription('test', () => { })); + const state = (status: 'cloning' | 'ready'): RootState => ({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { + schema: { type: 'object', properties: {} }, + values: { copilot: { projects: [{ id: 'checkout', path: '/checkout', git: true, status, remoteUrl: 'https://github.com/microsoft/vscode' }] } }, + }, + }); + root.handleSnapshot(state('cloning'), 0); + const harness = await createContribution(store, [], { connection: upcastPartial({ rootState: root }) }); + let ready = false; + const preparation = harness.contribution.prepareSession('env-new', 'microsoft/vscode', CancellationToken.None).then(() => ready = true); + await timeout(0); + const beforeReady = ready; + root.handleSnapshot(state('ready'), 1); + await preparation; + assert.deepStrictEqual({ beforeReady, afterReady: ready }, { beforeReady: false, afterReady: true }); + }); + + test('continues tracking a retry after an initially failed checkout', async () => { + const results: string[][] = []; + for (const outcome of ['ready', 'failed'] as const) { + const root = store.add(new RootStateSubscription('test', () => { })); + const state = (status: string, progress?: number): RootState => ({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { + schema: { type: 'object', properties: {} }, + values: { copilot: { projects: [{ id: 'checkout', path: '/checkout', git: true, status, progress, remoteUrl: 'https://github.com/microsoft/vscode' }] } }, + }, + }); + root.handleSnapshot(state('failed'), 0); + const harness = await createContribution(store, [], { connection: upcastPartial({ rootState: root }) }); + const messages: string[] = []; + store.add(harness.contribution.trackSessionCreationProgress('env-new', 'microsoft/vscode', { report: message => messages.push(message) })); + root.handleSnapshot(state('failed'), 1); + root.handleSnapshot(state('cloning', 0), 2); + root.handleSnapshot(state('cloning', 58), 3); + root.handleSnapshot(state(outcome), 4); + root.handleSnapshot(state('cloning', 1), 5); + results.push(messages); + } + assert.deepStrictEqual(results, [ + ['Cloning repository (0%)', 'Cloning repository (58%)', 'Starting Copilot agent'], + ['Cloning repository (0%)', 'Cloning repository (58%)', 'Repository cloning failed'], + ]); + }); + + test('keeps unknown clone percentages indeterminate and releases the observer on failure or disposal', async () => { + const results: string[][] = []; + for (const failed of [false, true]) { + const root = store.add(new RootStateSubscription('test', () => { })); + const state = (status: string): RootState => ({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { + schema: { type: 'object', properties: {} }, + values: { copilot: { projects: [{ id: 'checkout', path: '/checkout', git: true, status, remoteUrl: 'https://github.com/microsoft/vscode' }] } }, + }, + }); + root.handleSnapshot(state('cloning'), 0); + const harness = await createContribution(store, [], { connection: upcastPartial({ rootState: root }) }); + const messages: string[] = []; + const tracker = store.add(harness.contribution.trackSessionCreationProgress('env-new', 'microsoft/vscode', { report: message => messages.push(message) })); + if (failed) { + root.handleSnapshot(state('failed'), 1); + } else { + tracker.dispose(); + } + root.handleSnapshot(state('ready'), 2); + results.push(messages); + } + assert.deepStrictEqual(results, [['Cloning repository'], ['Cloning repository', 'Repository cloning failed']]); + }); + + test('does not invent clone progress for missing capabilities, projects, or connections', async () => { + const messages: string[] = []; + const root = store.add(new RootStateSubscription('test', () => { })); + root.handleSnapshot({ agents: [] }, 0); + const harness = await createContribution(store, [], { connection: upcastPartial({ rootState: root }) }); + store.add(harness.contribution.trackSessionCreationProgress('env-new', 'microsoft/vscode', { report: message => messages.push(message) })); + store.add(harness.contribution.trackSessionCreationProgress('missing', 'microsoft/vscode', { report: message => messages.push(message) })); + root.handleSnapshot({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { schema: { type: 'object', properties: {} }, values: { copilot: { projects: [] } } }, + }, 1); + assert.deepStrictEqual(messages, []); + }); + test('a discovery pass that cannot see the new task yet does not tear it down mid-provision', async () => { // The scan was issued before the task existed, so it reports the environment as absent. // Without the in-flight guard, reconciliation disposes the provider we are connecting to. @@ -1891,6 +2026,50 @@ suite('CloudSandboxAgentHostContribution provisioning', () => { }); }); + test('keeps cancelled provisioning protected from discovery until connection success or failure', async () => { + const results = []; + for (const success of [true, false]) { + const harness = await createContribution(store, []); + const connecting = new DeferredPromise(); + const connected = new DeferredPromise(); + let connectToken: CancellationToken | undefined; + harness.onConnect = async (_options, token) => { + connectToken = token; + await connecting.complete(); + await connected.p; + }; + const source = store.add(new CancellationTokenSource()); + const request = harness.contribution.provisionSession({ prompt: 'fix it' }, source.token); + const rejected = assert.rejects(request, isCancellationError); + await connecting.p; + source.cancel(); + await rejected; + await harness.runDiscovery(); + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-new')); + const beforeSettled = { + disposed: provider?.disposed, + connectCancelled: connectToken?.isCancellationRequested, + withheld: [...(provider?.withheld ?? [])], + listed: provider?.getSessions().map(session => AgentSession.id(session.resource)), + }; + const completion = harness.contribution.connect({ environmentId: 'env-new', name: 'Sandbox' }); + if (success) { + await connected.complete(); + await completion; + } else { + const failed = assert.rejects(completion, /connection failed/); + await connected.error(new Error('connection failed')); + await failed; + } + await harness.runDiscovery(); + results.push({ beforeSettled, afterSettled: provider?.disposed }); + } + assert.deepStrictEqual(results, [true, false].map(() => ({ + beforeSettled: { disposed: false, connectCancelled: false, withheld: [], listed: ['sess-new'] }, + afterSettled: true, + }))); + }); + test('rejects when the feature is disabled while the sandbox is waking', async () => { // Connecting waits out the VM boot, which is long enough for the setting to change. // Returning a provider that teardown has already disposed would send into nothing. From d9e6a014541725314e7e64f80b308cb072d674f9 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Sun, 4 Oct 2026 15:29:24 -0700 Subject: [PATCH 22/49] Describe result_token_count as a local text-output estimate (#339601) The GDPR comment for `copilotSdk/tool_call_executed.result_token_count` said the value is measured with the model tokenizer. The Copilot runtime computes it locally with `o200k_base` scaled by a per-model multiplier, and only for successful text results up to 20 KiB that carry no binary or structured-reference content. Say so, and state that absence is not zero, so consumers do not read the column as an exact or billed token count. Comment-only. The forwarder already passes SDK metrics through unchanged. Refs github/copilot-agent-runtime#20172 Refs #335623 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts index 4548df067b80..f614b36ce4e2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts @@ -225,7 +225,7 @@ type ModelCallTurnCorrelatedClassification = { "interaction_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier that correlates events in an interaction." }, "engagement_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier that correlates events in an engagement." }, "duration_ms": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Wall-clock duration of the tool execution in milliseconds.", "isMeasurement": true }, - "result_token_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Token count of the model-facing tool result, measured with the model tokenizer. Present only for successful results.", "isMeasurement": true }, + "result_token_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Local runtime estimate of the tokens in the tool's explicit model-facing text output (o200k_base scaled by a per-model multiplier); not an exact model-tokenizer count or billed usage. Reported only for successful text results up to 20 KiB without binary or structured-reference content; absence does not mean zero.", "isMeasurement": true }, "binary_result_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of binary result parts returned by the tool.", "isMeasurement": true }, "binary_result_total_bytes": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Total byte size of binary result parts returned by the tool.", "isMeasurement": true } } From c10f60fd7dc8edab1d3b5514b05c8810a86c3201 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sun, 4 Oct 2026 16:17:19 -0700 Subject: [PATCH 23/49] agentHost: fix cloud sandbox history replay (#339615) Skip identical persisted replication frames within each mirror epoch without weakening gap or conflict validation. Resolve the recorded default or sole chat instead of displaying an empty assumed chat. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/taskEventReplay.ts | 22 +++-- .../test/common/taskEventReplay.test.ts | 86 +++++++++++++++++-- 2 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/vs/platform/agentHost/common/taskEventReplay.ts b/src/vs/platform/agentHost/common/taskEventReplay.ts index bd68bd05dff5..6e1382a73eb8 100644 --- a/src/vs/platform/agentHost/common/taskEventReplay.ts +++ b/src/vs/platform/agentHost/common/taskEventReplay.ts @@ -14,6 +14,7 @@ // shape. The envelopes are folded by the same `sessionReducer` / `chatReducer` the live // subscriptions use, so a replayed session and a live one cannot drift. +import { equals } from '../../../base/common/objects.js'; import { ChunkEnvelope, Reassembler } from './webPubSub/chunking.js'; import { ActionEnvelope, ActionType, StateAction } from './state/protocol/common/actions.js'; import { chatReducer } from './state/protocol/channels-chat/reducer.js'; @@ -22,12 +23,7 @@ import { sessionReducer } from './state/protocol/channels-session/reducer.js'; import { SessionLifecycle, SessionState, SessionStatus } from './state/protocol/channels-session/state.js'; import { ChatAction, SessionAction } from './state/sessionActions.js'; -/** - * Highest transport sequence number that may legitimately appear *below* the expected next - * sequence. Mission Control re-hosts a dormant session on a fresh mirror process whose transport - * sequence restarts at 0 or 1 while `/events` continues with only the new actions; anything - * further back is a genuine gap. - */ +/** Highest starting sequence allowed for a new mirror epoch after exact duplicates are removed. */ const MAX_RESTART_EPOCH_INITIAL_SEQUENCE = 1; /** A persisted history that could not be decoded. Distinct from a transport/HTTP failure. */ @@ -71,6 +67,7 @@ export interface IReplayedTaskHistory { /** Per-session accumulator used while decoding the transport layer. */ interface ISessionReplayState { readonly envelopes: ActionEnvelope[]; + readonly eventsBySeq: Map>; modifiedAt: string; nextSeq: number; reassembler: Reassembler; @@ -150,7 +147,8 @@ function seedChatState(chatChannel: string, modifiedAt: string): ChatState { } /** - * Decode the persisted transport layer into ordered envelopes, grouped by session. + * Decode the persisted transport layer into ordered envelopes, grouped by session, ignoring exact + * duplicate records within each mirror epoch. * * Throws on a genuine sequence gap or a corrupt record — a history that cannot be trusted must * not be shown as if it were complete. @@ -172,10 +170,13 @@ function decodeEvents(events: readonly unknown[]): Map 0; + entry.eventsBySeq.clear(); entry.nextSeq = seq; entry.reassembler = new Reassembler(); } + entry.eventsBySeq.set(seq, value); + entry.modifiedAt = at; entry.nextSeq += 1; let reassembled: unknown; diff --git a/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts b/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts index d29ee9b95b46..f2b26a4172b2 100644 --- a/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts +++ b/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts @@ -11,11 +11,7 @@ import { parseTaskEventsResponse, replayTaskAhpEvents, TaskEventReplayError } fr const SESSION_A = 'ahp-session:/aaaaaaaa-0000-4000-8000-000000000001'; const SESSION_B = 'ahp-session:/bbbbbbbb-0000-4000-8000-000000000002'; -/** - * The default chat channel as Mission Control's recorded frames name it. Deliberately NOT - * `buildDefaultChatUri`: the host writes `/chat`, while a client builds - * `ahp-chat://default/` for the same chat. Replay must accept what was recorded. - */ +/** The legacy default chat convention; replay must also accept arbitrary host-provided channels. */ function defaultChat(sessionId: string): string { return `${sessionId}/chat`; } @@ -181,6 +177,86 @@ suite('Task event replay', () => { ['t1', 't2', 't3']); }); + test('ignores overlapping batches and interleaved duplicate deltas', () => { + const chat = defaultChat(SESSION_A); + const events = [ + event(SESSION_A, 1926, chat, turnStarted('t1', 'hello')), + event(SESSION_A, 1927, chat, { type: 'chat/responsePart', turnId: 't1', part: { kind: 'markdown', id: 'p1', content: '' } }), + event(SESSION_A, 1928, chat, { type: 'chat/delta', turnId: 't1', partId: 'p1', content: 'first ' }), + event(SESSION_A, 1929, chat, { type: 'chat/delta', turnId: 't1', partId: 'p1', content: 'second ' }), + event(SESSION_A, 1930, chat, { type: 'chat/delta', turnId: 't1', partId: 'p1', content: 'third' }), + event(SESSION_A, 1931, chat, turnComplete('t1')), + ]; + const history = replayTaskAhpEvents([ + ...events.slice(0, 4), + ...structuredClone(events.slice(2, 4)), + events[4], + structuredClone(events[2]), + events[5], + ]); + + assert.deepStrictEqual( + { + truncated: history?.truncated, + turns: history?.sessions[0].chats.get(chat)?.turns.map(t => ({ id: t.id, responseParts: t.responseParts })), + }, + { truncated: false, turns: [{ id: 't1', responseParts: [{ kind: 'markdown', id: 'p1', content: 'first second third' }] }] }); + }); + + test('ignores duplicate chunks without disrupting an in-flight group', () => { + const chat = defaultChat(SESSION_A); + const chunks = chunkedEvents(SESSION_A, 0, chat, turnStarted('t1', 'chunked'), 3); + const history = replayTaskAhpEvents([ + chunks[0], + structuredClone(chunks[0]), + chunks[1], + structuredClone(chunks[0]), + chunks[2], + ...structuredClone(chunks), + event(SESSION_A, 3, chat, turnComplete('t1')), + ]); + + assert.deepStrictEqual( + { truncated: history?.truncated, turns: history?.sessions[0].chats.get(chat)?.turns.map(t => t.id) }, + { truncated: false, turns: ['t1'] }); + }); + + test('does not rewind modifiedAt when skipping an older duplicate', () => { + const first = event(SESSION_A, 2, SESSION_A, titleChanged('first')); + const later = { ...event(SESSION_A, 3, SESSION_A, titleChanged('later')), at: '2026-08-04T13:00:00.000Z' }; + const history = replayTaskAhpEvents([first, later, structuredClone(first)]); + + assert.deepStrictEqual( + history?.sessions.map(s => ({ title: s.state.title, modifiedAt: s.modifiedAt })), + [{ title: 'later', modifiedAt: later.at }]); + }); + + test('clears duplicate tracking at a mirror restart', () => { + const repeated = event(SESSION_A, 2, SESSION_A, titleChanged('same in both epochs')); + const history = replayTaskAhpEvents([ + event(SESSION_A, 1, SESSION_A, titleChanged('first epoch')), + repeated, + event(SESSION_A, 3, SESSION_A, titleChanged('before restart')), + event(SESSION_A, 1, SESSION_A, titleChanged('second epoch')), + structuredClone(repeated), + event(SESSION_A, 3, SESSION_A, titleChanged('after restart')), + ]); + + assert.deepStrictEqual( + { truncated: history?.truncated, title: history?.sessions[0].state.title }, + { truncated: false, title: 'after restart' }); + }); + + test('rejects conflicting records instead of treating them as duplicates', () => { + const first = event(SESSION_A, 2, SESSION_A, titleChanged('first')); + for (const conflicting of [ + event(SESSION_A, 2, SESSION_A, titleChanged('different payload')), + { ...first, at: '2026-08-04T13:00:00.000Z' }, + ]) { + assert.throws(() => replayTaskAhpEvents([first, conflicting]), TaskEventReplayError); + } + }); + test('reports truncation from an epoch whose reassembler was replaced by a restart', () => { // The restart installs a fresh reassembler, so the incomplete group from the first epoch is // no longer buffered — the loss has to be remembered or the transcript reads as whole. From 3d51b7d4555ef45234d6c8761a7f7b2d423db83d Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Sun, 4 Oct 2026 16:35:48 -0700 Subject: [PATCH 24/49] Fix taskless subagent turn completion (#339619) * Fix taskless subagent turn completion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Cover taskless subagent completion timing and races Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Report taskless subagent failures as child turn errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/copilot/copilotAgentSession.ts | 96 ++++++++++++++++--- .../test/node/agentSideEffects.test.ts | 26 +++++ .../test/node/copilotAgentSession.test.ts | 82 +++++++++++++++- 3 files changed, 191 insertions(+), 13 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 88fe924af5f5..ff525c6f1077 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -175,6 +175,11 @@ interface IPendingMcpAuthRequest { readonly toolCalls: IMcpAuthToolCall[]; } +interface ISubagentLifecycleFailure { + readonly message: string; + readonly durationMs: number | undefined; +} + interface IMcpAuthToolCall { readonly turnId: string; readonly toolCallId: string; @@ -948,6 +953,7 @@ export class CopilotAgentSession extends Disposable { private readonly _autoModeResolvedByToolCallId = new Map>(); private readonly _activeSubagentAgentIds = new Set(); private readonly _subagentTaskCompletionSchedulers = this._register(new DisposableMap()); + private readonly _pendingSubagentLifecycleCompletions = new Map(); private readonly _subagentTaskCompletionDelay: number; /** Bumped when a child starts a model round so a task snapshot taken earlier cannot complete it; entries are dropped on completion. */ private readonly _subagentActivityRevisions = new Map(); @@ -1831,6 +1837,7 @@ export class CopilotAgentSession extends Disposable { private _completeSubagentTurn(agentId: string | undefined, toolCallId?: string): void { if (agentId) { this._subagentTaskCompletionSchedulers.deleteAndDispose(agentId); + this._pendingSubagentLifecycleCompletions.delete(agentId); this._subagentActivityRevisions.delete(agentId); if (!this._activeSubagentAgentIds.delete(agentId)) { return; @@ -1870,18 +1877,7 @@ export class CopilotAgentSession extends Disposable { this._autoModeResolvedByToolCallId.delete(parentToolCallId); } - private _scheduleSubagentTurnCompletion(agentId: string, toolCallId?: string): void { - if (!this._activeSubagentAgentIds.has(agentId)) { - return; - } - const parentToolCallId = toolCallId ?? this._parentToolCallIdsByAgentId.get(agentId); - if (!parentToolCallId) { - return; - } - if (this._subagentTaskCompletionDelay <= 0) { - this._completeSubagentTurn(agentId, parentToolCallId); - return; - } + private _getOrCreateSubagentCompletionScheduler(agentId: string): RunOnceScheduler { let scheduler = this._subagentTaskCompletionSchedulers.get(agentId); if (!scheduler) { const confirmation = new RunOnceScheduler(() => { @@ -1901,9 +1897,58 @@ export class CopilotAgentSession extends Disposable { scheduler = confirmation; this._subagentTaskCompletionSchedulers.set(agentId, scheduler); } + return scheduler; + } + + private _scheduleSubagentTurnCompletion(agentId: string, toolCallId?: string): void { + if (!this._activeSubagentAgentIds.has(agentId)) { + return; + } + const parentToolCallId = toolCallId ?? this._parentToolCallIdsByAgentId.get(agentId); + if (!parentToolCallId) { + return; + } + if (this._subagentTaskCompletionDelay <= 0) { + this._completeSubagentTurn(agentId, parentToolCallId); + return; + } + const scheduler = this._getOrCreateSubagentCompletionScheduler(agentId); scheduler.schedule(); } + private _scheduleSubagentLifecycleCompletion(agentId: string | undefined, toolCallId: string, failure?: ISubagentLifecycleFailure): void { + const mappedAgentId = agentId ?? [...this._parentToolCallIdsByAgentId].find(([, parentToolCallId]) => parentToolCallId === toolCallId)?.[0]; + if (!mappedAgentId) { + if (this._rootTurnIdBySubagentToolCallId.has(toolCallId)) { + this._emitSubagentFailure(toolCallId, failure); + this._completeSubagentTurn(undefined, toolCallId); + } + return; + } + if (!this._activeSubagentAgentIds.has(mappedAgentId)) { + return; + } + this._pendingSubagentLifecycleCompletions.set(mappedAgentId, { + toolCallId, + activityRevision: this._subagentActivityRevisions.get(mappedAgentId), + failure, + }); + this._getOrCreateSubagentCompletionScheduler(mappedAgentId).schedule(); + } + + /** Ends the child turn as failed; the completion that follows then finds no active turn. */ + private _emitSubagentFailure(toolCallId: string, failure: ISubagentLifecycleFailure | undefined): void { + if (!failure) { + return; + } + this._emitAction({ + type: ActionType.ChatError, + turnId: this._turnId, + duration: failure.durationMs ?? 0, + part: createErrorResponsePart({ errorType: 'subagentFailed', message: failure.message }), + }, toolCallId); + } + private _observeTokenUsage(parentToolCallId: string | undefined, eventId: string, model: string | undefined, scope: 'direct-model' | 'compaction', tokens: UsageContext, reasoningEffort?: string, apiCallId?: string): void { const recordId = apiCallId ? `api:${apiCallId}` : eventId ? `event:${eventId}` : undefined; const identity = recordId ? `${scope}\0${recordId}` : undefined; @@ -1961,10 +2006,12 @@ export class CopilotAgentSession extends Disposable { return false; } this._publishBackgroundWork(tasks.tasks); + const agentTaskIds = new Set(); for (const task of tasks.tasks) { if (task.type !== 'agent') { continue; } + agentTaskIds.add(task.id); const displayName = subagentChatTitle(task.description, task.displayName); if (displayName && !this._subagentDisplayNamesByAgentId.get(task.id)?.trim()) { this._subagentDisplayNamesByAgentId.set(task.id, displayName); @@ -1980,9 +2027,25 @@ export class CopilotAgentSession extends Disposable { this._scheduleSubagentTurnCompletion(task.id, task.toolCallId); } } else { + this._pendingSubagentLifecycleCompletions.delete(task.id); this._subagentTaskCompletionSchedulers.deleteAndDispose(task.id); } } + for (const [agentId, pending] of this._pendingSubagentLifecycleCompletions) { + if (agentTaskIds.has(agentId)) { + continue; + } + if (pending.activityRevision !== this._subagentActivityRevisions.get(agentId)) { + this._pendingSubagentLifecycleCompletions.delete(agentId); + this._subagentTaskCompletionSchedulers.deleteAndDispose(agentId); + continue; + } + const scheduler = this._subagentTaskCompletionSchedulers.get(agentId); + if (scheduler && !scheduler.isScheduled()) { + this._emitSubagentFailure(pending.toolCallId, pending.failure); + this._completeSubagentTurn(agentId, pending.toolCallId); + } + } return true; }); } @@ -7049,6 +7112,14 @@ export class CopilotAgentSession extends Disposable { this._updateSubagentModel(e.data.toolCallId, e.data.model); })); + this._register(wrapper.onSubagentCompleted(e => { + this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId); + })); + + this._register(wrapper.onSubagentFailed(e => { + this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, { message: e.data.error, durationMs: e.data.durationMs }); + })); + this._register(wrapper.onSubagentConfigured(e => { const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); if (!parentToolCallId) { @@ -8622,6 +8693,7 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onTurnStart(e => { if (e.agentId) { this._subagentActivityRevisions.set(e.agentId, (this._subagentActivityRevisions.get(e.agentId) ?? 0) + 1); + this._pendingSubagentLifecycleCompletions.delete(e.agentId); this._subagentTaskCompletionSchedulers.deleteAndDispose(e.agentId); } const turn = this._currentTurn.value; diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index f3feabf2e7fd..1b970c9b7375 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -7469,6 +7469,32 @@ suite('AgentSideEffects', () => { }); }); + test('a child-scoped error ends the subagent turn as failed before its completion signal', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-search', agentName: 'search_code_subagent', agentDisplayName: 'Search code', agentDescription: 'Searches' }); + const error = { errorType: 'subagentFailed', message: 'Search failed' }; + agent.fireProgress({ + kind: 'action', + resource: URI.parse(defaultChatUri), + parentToolCallId: 'tc-search', + action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 42, part: { kind: ResponsePartKind.Error, error } }, + }); + agent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(defaultChatUri), toolCallId: 'tc-search' }); + + const subState = stateManager.getSessionState(buildSubagentChatUri(sessionUri.toString(), 'tc-search')); + assert.deepStrictEqual({ + activeTurn: subState?.activeTurn, + turnStates: subState?.turns.map(turn => turn.state), + error: subState?.turns[0]?.responseParts.find(part => part.kind === ResponsePartKind.Error), + }, { + activeTurn: undefined, + turnStates: [TurnState.Error], + error: { kind: ResponsePartKind.Error, error }, + }); + }); + test('permission requests for inactive and unroutable subagents are denied', () => { setupSession(); startTurn('turn-1'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 7dbc98dd925e..e04e707e216e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -6593,6 +6593,86 @@ suite('CopilotAgentSession', () => { ]); }); + for (const eventType of ['subagent.completed', 'subagent.failed'] as const) { + test(`completes a taskless search subagent on ${eventType}`, async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-parent'); + const identity = { + toolCallId: 'tc-search', + agentName: 'search_code_subagent', + agentDisplayName: 'Search code', + }; + mockSession.fire('subagent.started', { + ...identity, + agentDescription: 'Search the codebase', + }, { agentId: 'search-agent' }); + + if (eventType === 'subagent.completed') { + mockSession.fire(eventType, identity, { agentId: 'search-agent' }); + } else { + mockSession.fire(eventType, { ...identity, error: 'Search failed', durationMs: 42 }, { agentId: 'search-agent' }); + } + await timeout(0); + + assert.deepStrictEqual({ + outcome: signals.flatMap<{ completed: string } | { error: unknown; duration: number; parentToolCallId: string | undefined }>(signal => signal.kind === 'subagent_completed' + ? [{ completed: signal.toolCallId }] + : signal.kind === 'action' && signal.action.type === ActionType.ChatError + ? [{ error: signal.action.part.error, duration: signal.action.duration, parentToolCallId: signal.parentToolCallId }] + : []), + taskListCalls: mockSession.backgroundTaskListCalls, + }, { + outcome: [ + ...(eventType === 'subagent.failed' + ? [{ error: { errorType: 'subagentFailed', message: 'Search failed' }, duration: 42, parentToolCallId: 'tc-search' }] + : []), + { completed: 'tc-search' }, + ], + taskListCalls: 1, + }); + }); + } + + test('completes a taskless subagent only after the completion quiet period', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables, { subagentTaskCompletionDelay: 20 }); + session.resetTurnState('turn-parent'); + const identity = { toolCallId: 'tc-search', agentName: 'search_code_subagent', agentDisplayName: 'Search code' }; + mockSession.fire('subagent.started', { ...identity, agentDescription: 'Search the codebase' }, { agentId: 'search-agent' }); + mockSession.fire('subagent.completed', identity, { agentId: 'search-agent' }); + const completed = () => signals.filter(signal => signal.kind === 'subagent_completed').map(signal => signal.toolCallId); + const beforeQuietPeriod = completed(); + await timeout(60); + + assert.deepStrictEqual({ beforeQuietPeriod, afterQuietPeriod: completed() }, { beforeQuietPeriod: [], afterQuietPeriod: ['tc-search'] }); + }); + + test('keeps a task-backed subagent active when its lifecycle completion races a running task', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables, { subagentTaskCompletionDelay: 20 }); + session.resetTurnState('turn-parent'); + const identity = { toolCallId: 'tc-subagent', agentName: 'explore', agentDisplayName: 'Explore' }; + mockSession.fire('subagent.started', { ...identity, agentDescription: 'Explore tests' }, { agentId: 'agent-1' }); + mockSession.backgroundTasks = [{ + type: 'agent', id: 'agent-1', toolCallId: 'tc-subagent', description: 'Explore tests', + status: 'running', agentType: 'explore', prompt: 'Explore tests', startedAt: new Date(0).toISOString(), + }]; + mockSession.fire('subagent.completed', identity, { agentId: 'agent-1' }); + await timeout(60); + + assert.deepStrictEqual(signals.filter(signal => signal.kind === 'subagent_completed'), []); + }); + + test('keeps a taskless subagent active when it starts another round before the quiet period ends', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables, { subagentTaskCompletionDelay: 20 }); + session.resetTurnState('turn-parent'); + const identity = { toolCallId: 'tc-search', agentName: 'search_code_subagent', agentDisplayName: 'Search code' }; + mockSession.fire('subagent.started', { ...identity, agentDescription: 'Search the codebase' }, { agentId: 'search-agent' }); + mockSession.fire('subagent.completed', identity, { agentId: 'search-agent' }); + mockSession.fire('assistant.turn_start', { turnId: 'next-round' }, { agentId: 'search-agent' }); + await timeout(60); + + assert.deepStrictEqual(signals.filter(signal => signal.kind === 'subagent_completed'), []); + }); + test('waits for subagent events to settle before completing an inactive task', async () => { const { session, mockSession, signals } = await createAgentSession(disposables, { subagentTaskCompletionDelay: 50 }); session.resetTurnState('turn-parent'); @@ -7399,7 +7479,7 @@ suite('CopilotAgentSession', () => { const data = { model: 'gpt-5.5', inputTokens: 5 } as SessionEventPayload<'assistant.usage'>['data']; mockSession.fire('assistant.usage', data, { agentId: 'child', id: 'child-usage-1' }); const first = session.getTurnTokenUsage('child-turn-1', 'child-tool'); - // Completion now flows through background-task reconciliation, not the subagent.completed event. + // Task-backed completion flows through background-task reconciliation. mockSession.backgroundTasks = [{ type: 'agent', id: 'child', toolCallId: 'child-tool', description: 'Explore', status: 'completed', agentType: 'explore', prompt: 'Explore', startedAt: new Date(0).toISOString(), From af89f26fd134de05f01a0099faf8d998f26b1421 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sun, 4 Oct 2026 16:43:52 -0700 Subject: [PATCH 25/49] copilot: update nested HTTP runtime to avoid navigator diagnostics (#339630) Fixes #336853 by adopting the upstream platform-specific environment implementation.\n\n(Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/copilot/package-lock.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 19df88d7dfee..d3a7c08ee7fb 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -266,9 +266,9 @@ } }, "node_modules/@azure/core-client/node_modules/@typespec/ts-http-runtime": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", - "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", "dev": true, "license": "MIT", "dependencies": { @@ -277,7 +277,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-http-compat": { @@ -387,9 +387,9 @@ } }, "node_modules/@azure/core-util/node_modules/@typespec/ts-http-runtime": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", - "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", "license": "MIT", "dependencies": { "http-proxy-agent": "^7.0.0", @@ -397,7 +397,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/identity": { @@ -520,9 +520,9 @@ } }, "node_modules/@azure/logger/node_modules/@typespec/ts-http-runtime": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", - "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", "license": "MIT", "dependencies": { "http-proxy-agent": "^7.0.0", @@ -530,7 +530,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/msal-browser": { From 539b7502df7dd3eb79f6d59f4f6e65cb0168e502 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sun, 4 Oct 2026 16:46:13 -0700 Subject: [PATCH 26/49] agentHost: distinguish cancelled subscriptions from missing resources (#339627) Fixes #334196 by classifying superseded subscribe requests from their ownership identity, returning an explicit cancellation error and logging expected cancellation at trace level. Preserve genuine missing-resource failures and newer subscription ownership. Add protocol races and real cold-restoration regression coverage. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/common/agentService.ts | 1 + .../agentHost/node/protocolServerHandler.ts | 17 ++- .../agentHost/test/node/agentService.test.ts | 57 ++++++- .../test/node/protocolServerHandler.test.ts | 142 +++++++++++++++++- 4 files changed, 210 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index a11b4896998e..72f57ecb9065 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -988,6 +988,7 @@ export interface IAgentService { * with {@link unsubscribe} when the subscription is released. When * provided, `isActive` is checked before registering the subscriber so a * request cancelled during asynchronous resolution cannot pin the resource. + * Callers must distinguish lost subscription ownership from a missing resource. */ subscribe(resource: URI, clientId: string, isActive?: () => boolean): Promise; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 613b99a8b291..0d957fbfd367 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -118,6 +118,13 @@ function jsonRpcErrorFrom(id: number, err: unknown): JsonRpcResponse { return jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, message); } +/** AHP has no cancellation-specific code, but a cancelled request still needs an error response. */ +class SubscriptionCancelledError extends ProtocolError { + constructor(channel: string) { + super(JSON_RPC_INTERNAL_ERROR, `Subscription cancelled: ${channel}`); + } +} + function shouldLogFailedRequest(method: string, params: unknown, err: unknown): boolean { if (!(err instanceof ProtocolError) || err.code !== AhpErrorCodes.NotFound || !isFileResourceRead(method, params)) { return true; @@ -1613,7 +1620,11 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien // is JSON over the wire, so narrowing at this boundary is safe. return { snapshot: snapshot as SubscribeResult['snapshot'] }; } catch (err) { - if (!pendingSubscription.active && client.subscriptions.get(classified.uri) === pendingSubscription) { + // Losing request ownership cancels the subscription, not the resource. + if (client.subscriptions.get(classified.uri) !== pendingSubscription) { + throw new SubscriptionCancelledError(params.channel); + } + if (!pendingSubscription.active) { client.subscriptions.delete(classified.uri); } if (err instanceof ProtocolError) { @@ -1893,7 +1904,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); client.transport.send(jsonRpcSuccess(id, result ?? null)); }).catch(err => { - if (shouldLogFailedRequest(method, params, err)) { + if (err instanceof SubscriptionCancelledError) { + this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} cancelled`, err.message); + } else if (shouldLogFailedRequest(method, params, err)) { this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); } client.transport.send(jsonRpcErrorFrom(id, err)); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index ac54390676d1..425c34248668 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -83,7 +83,7 @@ import { AGENT_MERGE_CHANGESET_ID, buildBranchChangesetUri, buildSessionChangese import { type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; import { getWorktreesRoot, WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT } from '../../node/shared/worktreeIsolation.js'; import { readSessionAdditionalWorktrees, writeSessionAdditionalWorktrees } from '../../node/shared/sessionAdditionalWorktrees.js'; -import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ArtifactServerToolName, SessionServerToolName } from '../../common/serverToolNames.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; @@ -15045,6 +15045,61 @@ suite('AgentService (node dispatcher)', () => { ); }); + test('superseding a turn changeset subscription during cold restore keeps the latest changeset available', async () => { + const localService = disposables.add(createTestAgentService( + new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + )); + registerTestAgentProvider(localService, copilotAgent); + const session = await localService.createSession({ provider: 'copilot' }); + const chat = buildDefaultChatUri(session); + const previous = buildTurnChangesetUri(chat, 'previous'); + const latest = buildTurnChangesetUri(chat, 'latest'); + const stateManager = getStateManager(localService); + stateManager.deleteSession(session.toString()); + const restoring = new DeferredPromise(); + const finishRestore = new DeferredPromise(); + copilotAgent.getChatCustomizations = async () => { + restoring.complete(); + await finishRestore.p; + return []; + }; + const emitted: ActionEnvelope[] = []; + disposables.add(localService.onDidAction(envelope => { + if (envelope.channel === previous || envelope.channel === latest) { + emitted.push(envelope); + } + })); + let isActive = true; + const subscribing = localService.subscribe(URI.parse(previous), 'client-turn', () => isActive); + const rejected = assert.rejects(subscribing, /Subscription cancelled/); + await restoring.p; + let snapshot: IStateSnapshot; + try { + isActive = false; + localService.unsubscribe(URI.parse(previous), 'client-turn'); + snapshot = await localService.subscribe(URI.parse(latest), 'client-turn'); + } finally { + finishRestore.complete(); + } + await rejected; + + assert.deepStrictEqual({ + snapshotResource: snapshot.resource, + previousAvailable: !!stateManager.getSnapshot(previous), + latestAvailable: !!stateManager.getSnapshot(latest), + previousPublished: emitted.some(envelope => envelope.channel === previous && envelope.action.type === ActionType.ChangesetContentChanged), + latestPublished: emitted.some(envelope => envelope.channel === latest && envelope.action.type === ActionType.ChangesetContentChanged), + }, { + snapshotResource: latest, + previousAvailable: true, + latestAvailable: true, + previousPublished: true, + latestPublished: true, + }); + localService.unsubscribe(URI.parse(latest), 'client-turn'); + }); + for (const cached of [undefined, [], ['file:///wd/cached.ts']]) { test(`subscribe to an uncommitted changeset returns ${cached ? 'cached' : 'uncached'} ${cached?.length ? 'files' : 'empty files'} before its refresh finishes`, async () => { const workingDirectory = URI.from({ scheme: Schemas.inMemory, path: '/wd' }); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index dfa706516c88..7c9b5c463db4 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -46,7 +46,7 @@ import { AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION, AgentHostClientConnecti import { AgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; -import { buildSessionChangesetUri } from '../../common/changesetUri.js'; +import { buildSessionChangesetUri, buildTurnChangesetUri } from '../../common/changesetUri.js'; import { MockDevContainerService } from '../common/mockDevContainerService.js'; import { McpAuthRequiredReason } from '../../common/state/protocol/channels-session/state.js'; @@ -1638,14 +1638,18 @@ suite('ProtocolServerHandler', () => { await Promise.resolve(); transport.simulateClose(); await agentService.subscribeBarrier.complete(); - await Promise.resolve(); + await handler.whenIdle(); assert.deepStrictEqual({ + response: findResponse(transport.sent, 2), subscribes: agentService.subscribeCalls, unsubscribes: agentService.unsubscribeCalls, + errorCount: logService.errorCount, }, { + response: { jsonrpc: '2.0', id: 2, error: { code: JSON_RPC_INTERNAL_ERROR, message: `Subscription cancelled: ${sessionUri}` } }, subscribes: [{ resource: sessionUri, clientId: 'client-1' }], unsubscribes: [{ resource: sessionUri, clientId: 'client-1' }], + errorCount: 0, }); }); @@ -1709,13 +1713,15 @@ suite('ProtocolServerHandler', () => { const [first, second] = await Promise.all([firstResponse, secondResponse]); assert.deepStrictEqual({ - firstFailed: hasKey(first, { error: true }), + firstError: hasKey(first, { error: true }) ? first.error : undefined, secondSucceeded: hasKey(second, { result: true }), + errorCount: logService.errorCount, subscribes: agentService.subscribeCalls, unsubscribes: agentService.unsubscribeCalls, }, { - firstFailed: true, + firstError: { code: JSON_RPC_INTERNAL_ERROR, message: `Subscription cancelled: ${sessionUri}` }, secondSucceeded: true, + errorCount: 0, subscribes: [ { resource: sessionUri, clientId: 'client-1' }, { resource: sessionUri, clientId: 'client-1' }, @@ -1724,6 +1730,134 @@ suite('ProtocolServerHandler', () => { }); }); + test('superseded turn changeset subscriptions are cancelled even when their resources exist', async () => { + stateManager.createSession(makeSessionSummary()); + const turns = ['previous-1', 'previous-2', 'latest'].map(turnId => buildTurnChangesetUri(defaultChatUri, turnId)); + const barrier = new DeferredPromise(); + for (const channel of turns) { + stateManager.registerChangeset(channel); + } + for (const channel of turns.slice(0, -1)) { + agentService.subscribeBarriers.set(channel, barrier); + } + const transport = connectClient('client-1'); + transport.sent.length = 0; + const responses = turns.map((_, index) => waitForResponse(transport, index + 2)); + const emitted: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => emitted.push(envelope))); + + for (const [index, channel] of turns.entries()) { + transport.simulateMessage(request(index + 2, 'subscribe', { channel })); + if (index < turns.length - 1) { + transport.simulateMessage(notification('unsubscribe', { channel })); + } + } + await responses[2]; + for (const channel of turns.slice(0, -1)) { + stateManager.dispatchServerAction(channel, { type: ActionType.ChangesetContentChanged, files: [] }); + stateManager.dispatchServerAction(channel, { type: ActionType.ChangesetStatusChanged, status: ChangesetStatus.Ready }); + } + await barrier.complete(); + const results = await Promise.all(responses); + + assert.deepStrictEqual({ + errors: results.slice(0, -1).map(result => hasKey(result, { error: true }) ? result.error : undefined), + latestResource: hasKey(results[2], { result: true }) ? (results[2].result as SubscribeResult).snapshot?.resource : undefined, + supersededResourcesExist: turns.slice(0, -1).map(channel => !!stateManager.getSnapshot(channel)), + emitted: emitted.map(envelope => ({ channel: envelope.channel, type: envelope.action.type })), + deliveredActions: findNotifications(transport.sent, 'action').length, + unsubscribes: agentService.unsubscribeCalls, + errorCount: logService.errorCount, + }, { + errors: turns.slice(0, -1).map(channel => ({ code: JSON_RPC_INTERNAL_ERROR, message: `Subscription cancelled: ${channel}` })), + latestResource: turns[2], + supersededResourcesExist: [true, true], + emitted: turns.slice(0, -1).flatMap(channel => [ + { channel, type: ActionType.ChangesetContentChanged }, + { channel, type: ActionType.ChangesetStatusChanged }, + ]), + deliveredActions: 0, + unsubscribes: turns.slice(0, -1).map(resource => ({ resource, clientId: 'client-1' })), + errorCount: 0, + }); + }); + + test('unsubscribe cancels a subscribe whose snapshot resolves successfully afterwards', async () => { + stateManager.createSession(makeSessionSummary()); + const barrier = new DeferredPromise(); + agentService.subscribe = async () => barrier.p; + const transport = connectClient('client-1'); + transport.sent.length = 0; + const response = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'subscribe', { channel: sessionUri })); + transport.simulateMessage(notification('unsubscribe', { channel: sessionUri })); + await barrier.complete(stateManager.getSnapshot(sessionUri)!); + + assert.deepStrictEqual({ + response: await response, + errorCount: logService.errorCount, + unsubscribes: agentService.unsubscribeCalls, + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: JSON_RPC_INTERNAL_ERROR, message: `Subscription cancelled: ${sessionUri}` } }, + errorCount: 0, + unsubscribes: [{ resource: sessionUri, clientId: 'client-1' }], + }); + }); + + test('subscribe still reports and logs genuinely missing resources', async () => { + const transport = connectClient('client-1'); + transport.sent.length = 0; + const channel = buildTurnChangesetUri(defaultChatUri, 'missing'); + const response = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'subscribe', { channel })); + + assert.deepStrictEqual({ + response: await response, + errorCount: logService.errorCount, + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: AHP_SESSION_NOT_FOUND, message: `Resource not found: ${channel}` } }, + errorCount: 1, + }); + }); + + test('subscribe preserves and logs protocol failures while it still owns the subscription', async () => { + const error = new ProtocolError(AhpErrorCodes.AuthRequired, 'Authentication required', { resources: [] }); + agentService.subscribe = async () => { throw error; }; + const transport = connectClient('client-1'); + transport.sent.length = 0; + const response = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'subscribe', { channel: sessionUri })); + + assert.deepStrictEqual({ + response: await response, + errorCount: logService.errorCount, + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: error.code, message: error.message, data: error.data } }, + errorCount: 1, + }); + }); + + test('unsubscribe cancels a subscribe whose restore rejects with a protocol error afterwards', async () => { + const barrier = new DeferredPromise(); + agentService.subscribe = async () => barrier.p; + const transport = connectClient('client-1'); + transport.sent.length = 0; + const response = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'subscribe', { channel: sessionUri })); + transport.simulateMessage(notification('unsubscribe', { channel: sessionUri })); + await barrier.error(new ProtocolError(AhpErrorCodes.AuthRequired, 'Authentication required', { resources: [] })); + + assert.deepStrictEqual({ + response: await response, + errorCount: logService.errorCount, + unsubscribes: agentService.unsubscribeCalls, + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: JSON_RPC_INTERNAL_ERROR, message: `Subscription cancelled: ${sessionUri}` } }, + errorCount: 0, + unsubscribes: [{ resource: sessionUri, clientId: 'client-1' }], + }); + }); + test('client action is dispatched and echoed', async () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); From b3aa323f07861093e148e4900e8edb260d8359e0 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sun, 4 Oct 2026 16:46:15 -0700 Subject: [PATCH 27/49] chat: Fix Cloud Sandbox slash command suggestions (#339629) * chat: fix Cloud Sandbox slash command suggestions Filter plain-text agent host completions by their insertion text instead of their display label, and show slash command descriptions separately in both chat inputs. Add regression coverage for Monaco filtering and the Agents draft composer. Fixes microsoft/vscode-internalbacklog#9797 (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: preserve slash completion labels with acceptance spaces Avoid treating the command name as a description when the insertion text includes a trailing acceptance space. Cover both trimmed and exact command display labels while retaining descriptive Cloud Sandbox labels. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/agentHostInputCompletions.ts | 8 +- .../browser/agentHostInputCompletions.test.ts | 54 +++++++++++++- .../input/editor/agentHostInputCompletions.ts | 8 +- .../editor/agentHostInputCompletionsBase.ts | 12 +++ .../input/editor/chatInputCompletions.test.ts | 73 +++++++++++++++++++ 5 files changed, 139 insertions(+), 16 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts b/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts index 35fb1dfba82d..1e57b6083d46 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts @@ -299,13 +299,7 @@ export class AgentHostInputCompletionHandler extends AgentHostInputCompletionsBa const attachment = item.attachment; switch (attachment.kind) { case 'text': - return { - label: item.label ?? item.insertText, - insertText: item.insertText, - filterText: item.label ?? item.insertText, - range: replaceRange, - kind: CompletionItemKind.Text, - }; + return AgentHostInputCompletionHandler.buildTextCompletionItem(position, item); case 'command': { const action = getCompletionAction(attachment._meta); if (action) { diff --git a/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts index efcec8c31575..4911b08c7403 100644 --- a/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts @@ -4,14 +4,64 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { constObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Position } from '../../../../../editor/common/core/position.js'; +import { Range } from '../../../../../editor/common/core/range.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { CompletionItem, CompletionItemKind } from '../../../../../editor/common/languages.js'; +import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; +import { withTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { IChatInputCompletionItem, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IChatRequestVariableEntry, toAgentHostCompletionVariableEntry, AgentHostCompletionReferenceKind } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { getAgentHostCompletionAttachmentRange, getCommandArgumentHintPlaceholder } from '../../browser/agentHostInputCompletions.js'; +import { ISessionContext } from '../../../../services/sessions/browser/sessionContext.js'; +import { AgentHostInputCompletionHandler, getAgentHostCompletionAttachmentRange, getCommandArgumentHintPlaceholder } from '../../browser/agentHostInputCompletions.js'; +import { INewChatAttachments } from '../../browser/newChatContextAttachments.js'; + +class TestableAgentHostInputCompletionHandler extends AgentHostInputCompletionHandler { + buildItem(position: Position, item: IChatInputCompletionItem): CompletionItem | undefined { + return this._buildItem(position, item); + } +} suite('AgentHostInputCompletions', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('shows plain-text sandbox slash commands without adding an attachment', async () => { + const services = new ServiceCollection( + [ISessionContext, { _serviceBrand: undefined, session: constObservable(undefined) }], + [IChatSessionsService, new class extends mock() { }], + ); + const model = store.add(createTextModel('/', null, undefined, URI.parse('test:input'))); + await withTestCodeEditor(model, { serviceCollection: services }, async (editor, _viewModel, instantiationService) => { + const attachments = new class extends mock() { + override readonly onDidChangeContext = Event.None; + override readonly attachments = []; + }; + const handler = store.add(instantiationService.createInstance(TestableAgentHostInputCompletionHandler, editor, attachments, async () => true)); + assert.deepStrictEqual(handler.buildItem(new Position(1, 2), { + insertText: '/review', + label: 'Review the workspace', + start: { lineNumber: 1, column: 1 }, + end: { lineNumber: 1, column: 2 }, + attachment: { kind: 'text' }, + }), { + label: { label: '/review', description: 'Review the workspace' }, + insertText: '/review', + filterText: '/review', + range: { + insert: new Range(1, 1, 1, 2), + replace: new Range(1, 1, 1, 2), + }, + kind: CompletionItemKind.Text, + }); + }); + }); test('uses the accepted occurrence when duplicate slash tokens exist', () => { const text = 'first /rename then accepted /rename'; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts index bf4cfd23c619..310ddea494e0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts @@ -176,13 +176,7 @@ export class AgentHostInputCompletions extends AgentHostInputCompletionsBase e const insert = new Range(start.lineNumber, start.column, position.lineNumber, position.column); return { insert, replace }; } + + protected static buildTextCompletionItem(position: Position, item: IChatInputCompletionItem): CompletionItem { + return { + label: item.insertText.startsWith('/') && item.label !== undefined && item.label !== item.insertText && item.label !== item.insertText.trimEnd() + ? { label: item.insertText.trimEnd(), description: item.label } + : item.label ?? item.insertText, + insertText: item.insertText, + filterText: item.insertText, + range: this.computeRange(position, item), + kind: CompletionItemKind.Text, + }; + } } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts index 1be16b2a176c..b75dd9ac0c1c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts @@ -15,6 +15,9 @@ import { Range } from '../../../../../../../../editor/common/core/range.js'; import { CompletionItem, CompletionItemKind, CompletionTriggerKind } from '../../../../../../../../editor/common/languages.js'; import { ITextModel } from '../../../../../../../../editor/common/model.js'; import { LanguageFeaturesService } from '../../../../../../../../editor/common/services/languageFeaturesService.js'; +import { CompletionModel } from '../../../../../../../../editor/contrib/suggest/browser/completionModel.js'; +import { CompletionItem as SuggestCompletionItem } from '../../../../../../../../editor/contrib/suggest/browser/suggest.js'; +import { WordDistance } from '../../../../../../../../editor/contrib/suggest/browser/wordDistance.js'; import { createTextModel } from '../../../../../../../../editor/test/common/testTextModel.js'; import { AgentHostInputCompletionsBase } from '../../../../../browser/widget/input/editor/agentHostInputCompletionsBase.js'; import { AgentHostInputCompletions } from '../../../../../browser/widget/input/editor/agentHostInputCompletions.js'; @@ -277,6 +280,76 @@ suite('AgentHostInputCompletions plain text', () => { kind: CompletionItemKind.Text, }); }); + + test('preserves slash command labels that differ only by an acceptance space', () => { + const completions = store.add(new TestableAgentHostInputCompletions( + new LanguageFeaturesService(), + new MockChatWidgetService(), + new TestChatSessionsService(), + new TestConfigurationService(), + )); + const results = ['/review', '/review '].map(label => { + const built = completions.buildItem(new Position(1, 2), { + insertText: '/review ', + label, + attachment: { kind: 'text' }, + }, upcastPartial({})); + return { + label: built?.label, + insertText: built?.insertText, + filterText: built?.filterText, + }; + }); + + assert.deepStrictEqual(results, [ + { label: '/review', insertText: '/review ', filterText: '/review ' }, + { label: '/review ', insertText: '/review ', filterText: '/review ' }, + ]); + }); + + test('keeps plain-text slash commands visible when the host labels them with descriptions', () => { + const completions = store.add(new TestableAgentHostInputCompletions( + new LanguageFeaturesService(), + new MockChatWidgetService(), + new TestChatSessionsService(), + new TestConfigurationService(), + )); + const results = ['/', '/rev'].map(text => { + const position = new Position(1, text.length + 1); + const built = completions.buildItem(position, { + insertText: '/review ', + label: 'Review the workspace', + start: { lineNumber: 1, column: 1 }, + end: position, + attachment: { kind: 'text' }, + }, upcastPartial({}))!; + const list = { suggestions: [built] }; + const provider = { _debugDisplayName: 'testPlainTextSlashCommands', provideCompletionItems: () => list }; + const model = new CompletionModel( + [new SuggestCompletionItem(position, built, list, provider)], + position.column, + { leadingLineContent: text, characterCountDelta: 0 }, + WordDistance.None, + EditorOptions.suggest.defaultValue, + EditorOptions.snippetSuggestions.defaultValue, + undefined, + ); + return model.items.map(item => ({ + label: item.completion.label, + insertText: item.completion.insertText, + filterText: item.completion.filterText, + command: item.completion.command, + })); + }); + + const expected = [{ + label: { label: '/review', description: 'Review the workspace' }, + insertText: '/review ', + filterText: '/review ', + command: undefined, + }]; + assert.deepStrictEqual(results, [expected, expected]); + }); }); suite('AgentHostInputCompletions skills', () => { From 3f4647a7f9c7f8e154a8e2be928bc9710cd5950e Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Sun, 4 Oct 2026 17:30:56 -0700 Subject: [PATCH 28/49] Report failed and cancelled subagent turn outcomes (#339628) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/copilot/copilotAgentSession.ts | 76 ++++++++++------ .../test/node/agentSideEffects.test.ts | 23 +++++ .../test/node/copilotAgentSession.test.ts | 86 +++++++++++++++++++ 3 files changed, 159 insertions(+), 26 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index ff525c6f1077..ef1308366cbe 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -175,10 +175,9 @@ interface IPendingMcpAuthRequest { readonly toolCalls: IMcpAuthToolCall[]; } -interface ISubagentLifecycleFailure { - readonly message: string; - readonly durationMs: number | undefined; -} +type SubagentTurnOutcome = + | { readonly kind: 'failed'; readonly message: string; readonly durationMs: number | undefined } + | { readonly kind: 'cancelled'; readonly durationMs: number | undefined }; interface IMcpAuthToolCall { readonly turnId: string; @@ -953,7 +952,7 @@ export class CopilotAgentSession extends Disposable { private readonly _autoModeResolvedByToolCallId = new Map>(); private readonly _activeSubagentAgentIds = new Set(); private readonly _subagentTaskCompletionSchedulers = this._register(new DisposableMap()); - private readonly _pendingSubagentLifecycleCompletions = new Map(); + private readonly _pendingSubagentLifecycleCompletions = new Map(); private readonly _subagentTaskCompletionDelay: number; /** Bumped when a child starts a model round so a task snapshot taken earlier cannot complete it; entries are dropped on completion. */ private readonly _subagentActivityRevisions = new Map(); @@ -1900,7 +1899,7 @@ export class CopilotAgentSession extends Disposable { return scheduler; } - private _scheduleSubagentTurnCompletion(agentId: string, toolCallId?: string): void { + private _scheduleSubagentTurnCompletion(agentId: string, toolCallId?: string, outcome?: SubagentTurnOutcome): void { if (!this._activeSubagentAgentIds.has(agentId)) { return; } @@ -1909,18 +1908,18 @@ export class CopilotAgentSession extends Disposable { return; } if (this._subagentTaskCompletionDelay <= 0) { - this._completeSubagentTurn(agentId, parentToolCallId); + this._settleSubagentTurn(agentId, parentToolCallId, outcome); return; } const scheduler = this._getOrCreateSubagentCompletionScheduler(agentId); scheduler.schedule(); } - private _scheduleSubagentLifecycleCompletion(agentId: string | undefined, toolCallId: string, failure?: ISubagentLifecycleFailure): void { + private _scheduleSubagentLifecycleCompletion(agentId: string | undefined, toolCallId: string, outcome?: SubagentTurnOutcome): void { const mappedAgentId = agentId ?? [...this._parentToolCallIdsByAgentId].find(([, parentToolCallId]) => parentToolCallId === toolCallId)?.[0]; if (!mappedAgentId) { if (this._rootTurnIdBySubagentToolCallId.has(toolCallId)) { - this._emitSubagentFailure(toolCallId, failure); + this._emitSubagentOutcome(toolCallId, outcome); this._completeSubagentTurn(undefined, toolCallId); } return; @@ -1931,22 +1930,48 @@ export class CopilotAgentSession extends Disposable { this._pendingSubagentLifecycleCompletions.set(mappedAgentId, { toolCallId, activityRevision: this._subagentActivityRevisions.get(mappedAgentId), - failure, + outcome, }); this._getOrCreateSubagentCompletionScheduler(mappedAgentId).schedule(); } - /** Ends the child turn as failed; the completion that follows then finds no active turn. */ - private _emitSubagentFailure(toolCallId: string, failure: ISubagentLifecycleFailure | undefined): void { - if (!failure) { - return; + /** Completes an active child turn, first ending it as failed or cancelled when it did not succeed. */ + private _settleSubagentTurn(agentId: string, toolCallId: string, outcome: SubagentTurnOutcome | undefined): void { + if (this._activeSubagentAgentIds.has(agentId)) { + this._emitSubagentOutcome(toolCallId, outcome); + } + this._completeSubagentTurn(agentId, toolCallId); + } + + /** Task status is authoritative; a pending lifecycle event only supplies details the status lacks. */ + private _taskOutcome(task: Extract>['tasks'][number], { type: 'agent' }>): SubagentTurnOutcome | undefined { + const pending = this._pendingSubagentLifecycleCompletions.get(task.id)?.outcome; + switch (task.status) { + case 'failed': + return pending?.kind === 'failed' ? pending : { kind: 'failed', message: task.error ?? localize('copilotAgent.subagentFailed', "Subagent failed."), durationMs: task.activeTimeMs }; + case 'cancelled': + return pending?.kind === 'cancelled' ? pending : { kind: 'cancelled', durationMs: task.activeTimeMs }; + default: + return undefined; + } + } + + /** Ends the child turn as failed or cancelled; the completion that follows then finds no active turn. */ + private _emitSubagentOutcome(toolCallId: string, outcome: SubagentTurnOutcome | undefined): void { + if (outcome?.kind === 'failed') { + this._emitAction({ + type: ActionType.ChatError, + turnId: this._turnId, + duration: outcome.durationMs ?? 0, + part: createErrorResponsePart({ errorType: 'subagentFailed', message: outcome.message }), + }, toolCallId); + } else if (outcome?.kind === 'cancelled') { + this._emitAction({ + type: ActionType.ChatTurnCancelled, + turnId: this._turnId, + duration: outcome.durationMs ?? 0, + }, toolCallId); } - this._emitAction({ - type: ActionType.ChatError, - turnId: this._turnId, - duration: failure.durationMs ?? 0, - part: createErrorResponsePart({ errorType: 'subagentFailed', message: failure.message }), - }, toolCallId); } private _observeTokenUsage(parentToolCallId: string | undefined, eventId: string, model: string | undefined, scope: 'direct-model' | 'compaction', tokens: UsageContext, reasoningEffort?: string, apiCallId?: string): void { @@ -2022,9 +2047,9 @@ export class CopilotAgentSession extends Disposable { if (task.status === 'idle' || task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { const scheduler = this._subagentTaskCompletionSchedulers.get(task.id); if (scheduler && !scheduler.isScheduled()) { - this._completeSubagentTurn(task.id, task.toolCallId); + this._settleSubagentTurn(task.id, task.toolCallId, this._taskOutcome(task)); } else if (!scheduler) { - this._scheduleSubagentTurnCompletion(task.id, task.toolCallId); + this._scheduleSubagentTurnCompletion(task.id, task.toolCallId, this._taskOutcome(task)); } } else { this._pendingSubagentLifecycleCompletions.delete(task.id); @@ -2042,8 +2067,7 @@ export class CopilotAgentSession extends Disposable { } const scheduler = this._subagentTaskCompletionSchedulers.get(agentId); if (scheduler && !scheduler.isScheduled()) { - this._emitSubagentFailure(pending.toolCallId, pending.failure); - this._completeSubagentTurn(agentId, pending.toolCallId); + this._settleSubagentTurn(agentId, pending.toolCallId, pending.outcome); } } return true; @@ -7113,11 +7137,11 @@ export class CopilotAgentSession extends Disposable { })); this._register(wrapper.onSubagentCompleted(e => { - this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId); + this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, e.data.cancelled ? { kind: 'cancelled', durationMs: e.data.durationMs } : undefined); })); this._register(wrapper.onSubagentFailed(e => { - this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, { message: e.data.error, durationMs: e.data.durationMs }); + this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, { kind: 'failed', message: e.data.error, durationMs: e.data.durationMs }); })); this._register(wrapper.onSubagentConfigured(e => { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 1b970c9b7375..98392482c7ee 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -7495,6 +7495,29 @@ suite('AgentSideEffects', () => { }); }); + test('a child-scoped cancellation ends the subagent turn as cancelled before its completion signal', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-task', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); + agent.fireProgress({ + kind: 'action', + resource: URI.parse(defaultChatUri), + parentToolCallId: 'tc-task', + action: { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 42 }, + }); + agent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(defaultChatUri), toolCallId: 'tc-task' }); + + const subState = stateManager.getSessionState(buildSubagentChatUri(sessionUri.toString(), 'tc-task')); + assert.deepStrictEqual({ + activeTurn: subState?.activeTurn, + turnStates: subState?.turns.map(turn => turn.state), + }, { + activeTurn: undefined, + turnStates: [TurnState.Cancelled], + }); + }); + test('permission requests for inactive and unroutable subagents are denied', () => { setupSession(); startTurn('turn-1'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index e04e707e216e..21c650f0117b 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -6633,6 +6633,92 @@ suite('CopilotAgentSession', () => { }); } + suite('subagent turn outcomes', () => { + const identity = { toolCallId: 'tc-subagent', agentName: 'explore', agentDisplayName: 'Explore' }; + const agentTask = (status: 'idle' | 'failed' | 'cancelled', error?: string) => ({ + type: 'agent' as const, id: 'agent-1', toolCallId: 'tc-subagent', description: 'Explore tests', + status, agentType: 'explore', prompt: 'Explore tests', startedAt: new Date(0).toISOString(), + activeTimeMs: 1500, ...(error ? { error } : {}), + } satisfies Extract); + const outcomes = (signals: readonly AgentSignal[]) => signals.flatMap<{ completed: string } | { type: ActionType; duration: number; error?: unknown; parentToolCallId: string | undefined }>(signal => { + if (signal.kind === 'subagent_completed') { + return [{ completed: signal.toolCallId }]; + } + if (signal.kind === 'action' && (signal.action.type === ActionType.ChatError || signal.action.type === ActionType.ChatTurnCancelled)) { + return [{ + type: signal.action.type, + duration: signal.action.duration, + ...(signal.action.type === ActionType.ChatError ? { error: signal.action.part.error } : {}), + parentToolCallId: signal.parentToolCallId, + }]; + } + return []; + }); + + async function startSubagent() { + const created = await createAgentSession(disposables); + created.session.resetTurnState('turn-parent'); + created.mockSession.fire('subagent.started', { ...identity, agentDescription: 'Explore tests' }, { agentId: 'agent-1' }); + return created; + } + + test('ends a failed task subagent as an error using the task error', async () => { + const { mockSession, signals } = await startSubagent(); + mockSession.backgroundTasks = [agentTask('failed', 'Model request failed')]; + mockSession.fire('session.background_tasks_changed', {}); + await timeout(0); + + assert.deepStrictEqual(outcomes(signals), [ + { type: ActionType.ChatError, duration: 1500, error: { errorType: 'subagentFailed', message: 'Model request failed' }, parentToolCallId: 'tc-subagent' }, + { completed: 'tc-subagent' }, + ]); + }); + + test('prefers the subagent.failed details for a failed task subagent', async () => { + const { mockSession, signals } = await startSubagent(); + mockSession.backgroundTasks = [agentTask('failed')]; + mockSession.fire('subagent.failed', { ...identity, error: 'No response generated', durationMs: 42 }, { agentId: 'agent-1' }); + await timeout(0); + + assert.deepStrictEqual(outcomes(signals), [ + { type: ActionType.ChatError, duration: 42, error: { errorType: 'subagentFailed', message: 'No response generated' }, parentToolCallId: 'tc-subagent' }, + { completed: 'tc-subagent' }, + ]); + }); + + test('ends a cancelled task subagent as cancelled', async () => { + const { mockSession, signals } = await startSubagent(); + mockSession.backgroundTasks = [agentTask('cancelled')]; + mockSession.fire('session.background_tasks_changed', {}); + await timeout(0); + + assert.deepStrictEqual(outcomes(signals), [ + { type: ActionType.ChatTurnCancelled, duration: 1500, parentToolCallId: 'tc-subagent' }, + { completed: 'tc-subagent' }, + ]); + }); + + test('ends a taskless subagent torn down by the runtime as cancelled', async () => { + const { mockSession, signals } = await startSubagent(); + mockSession.fire('subagent.completed', { ...identity, cancelled: true, durationMs: 7 }, { agentId: 'agent-1' }); + await timeout(0); + + assert.deepStrictEqual(outcomes(signals), [ + { type: ActionType.ChatTurnCancelled, duration: 7, parentToolCallId: 'tc-subagent' }, + { completed: 'tc-subagent' }, + ]); + }); + + test('keeps an idle task subagent successful', async () => { + const { mockSession, signals } = await startSubagent(); + mockSession.backgroundTasks = [agentTask('idle')]; + mockSession.fire('subagent.completed', identity, { agentId: 'agent-1' }); + await timeout(0); + + assert.deepStrictEqual(outcomes(signals), [{ completed: 'tc-subagent' }]); + }); + }); + test('completes a taskless subagent only after the completion quiet period', async () => { const { session, mockSession, signals } = await createAgentSession(disposables, { subagentTaskCompletionDelay: 20 }); session.resetTurnState('turn-parent'); From 3edf2b932bb70f6b109c86798230ab3889c30128 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sun, 4 Oct 2026 17:43:41 -0700 Subject: [PATCH 29/49] chat: align sandbox Copilot behavior with Copilot CLI (#339631) Recognize both Copilot agent-host provider IDs for response identity, unsaved editor attachments, semantic search, and telemetry categorization without rewriting URI schemes or protocol identifiers. Written by Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/agentHostSessionType.ts | 12 +++ .../test/common/agentHostSessionType.test.ts | 27 ++++- .../agentHost/agentHostActiveClientService.ts | 4 +- .../agentHost/agentHostSessionHandler.ts | 17 ++-- .../chat/browser/widget/chatListRenderer.ts | 13 +-- .../chatService/chatServiceTelemetry.ts | 4 +- .../contrib/chat/common/editorChatUsage.ts | 9 +- .../agentHostChatContribution.test.ts | 99 ++++++++++++++----- .../agentHostClientTools.test.ts | 10 +- .../browser/widget/chatListRenderer.test.ts | 17 ++++ .../chatService/chatRequestTelemetry.test.ts | 7 +- .../chat/test/common/editorChatUsage.test.ts | 6 +- 12 files changed, 166 insertions(+), 59 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostSessionType.ts b/src/vs/platform/agentHost/common/agentHostSessionType.ts index ff096518865a..2513c175d0a7 100644 --- a/src/vs/platform/agentHost/common/agentHostSessionType.ts +++ b/src/vs/platform/agentHost/common/agentHostSessionType.ts @@ -8,6 +8,18 @@ import { type AgentProvider } from './agent.js'; const LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX = 'agent-host-'; const REMOTE_AGENT_HOST_SESSION_TYPE_PREFIX = 'remote-'; +/** Identifies the Copilot agent family without changing the host's provider ID or implying runtime capabilities. */ +export function isCopilotAgentHostProvider(provider: AgentProvider | undefined): boolean { + return provider === 'copilotcli' || provider === 'copilot'; +} + +/** Identifies local and remote Copilot agent-host session types, preserving their existing URI schemes. */ +export function isCopilotAgentHostSessionType(sessionType: string): boolean { + const provider = parseAgentHostHarness(sessionType); + return provider !== undefined && isCopilotAgentHostProvider(provider) + && (!isRemoteAgentHostSessionType(sessionType) || parseRemoteAgentHostSessionTypeAuthority(sessionType, provider) !== undefined); +} + /** * Builds the unique per-connection identifier for a remote agent host. * diff --git a/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts b/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts index 3cf209d292cc..11e072a68b18 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts @@ -5,11 +5,36 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, parseAgentHostHarness, parseRemoteAgentHostHarness, parseRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../common/agentHostSessionType.js'; +import { findRemoteAgentHostSessionTypeAuthority, isCopilotAgentHostProvider, isCopilotAgentHostSessionType, isRemoteAgentHostSessionType, parseAgentHostHarness, parseRemoteAgentHostHarness, parseRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../common/agentHostSessionType.js'; suite('agentHostSessionType', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('recognizes both Copilot provider IDs without matching other providers', () => { + assert.deepStrictEqual( + ['copilotcli', 'copilot', 'claude', 'codex', 'copilotcloud', undefined].map(isCopilotAgentHostProvider), + [true, true, false, false, false, false], + ); + }); + + test('recognizes local and remote Copilot session types without changing their schemes', () => { + assert.deepStrictEqual([ + 'agent-host-copilotcli', + 'agent-host-copilot', + 'remote-dev-box-copilotcli', + 'remote-cloudsandbox_environment-one-copilot', + 'agent-host-claude', + 'remote-dev-box-codex', + 'copilotcli', + 'copilot', + 'copilotcloud', + 'remote--copilot', + 'remote-dev-box-copilot-other', + ].map(isCopilotAgentHostSessionType), [ + true, true, true, true, false, false, false, false, false, false, false, + ]); + }); + test('remoteAgentHostSessionTypeId pins the wire format', () => { assert.deepStrictEqual([ remoteAgentHostSessionTypeId('foo', 'copilot'), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts index 3619d84e3d65..e6e9ce60b2bc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -14,7 +14,7 @@ import { equals } from '../../../../../../base/common/objects.js'; import { autorun, derived, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { type IExtUri } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { isRemoteAgentHostSessionType } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; +import { isCopilotAgentHostSessionType, isRemoteAgentHostSessionType } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; import type { AgentCustomization, SessionActiveClient, ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import type { ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME, CLIENT_SEMANTIC_SEARCH_TOOL_ID, CopilotSemanticSearchEnabledSettingId, SEMANTIC_SEARCH_TOOL_NAME } from '../../../../../../platform/agentHost/common/semanticSearchConstants.js'; @@ -380,7 +380,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo const tools = this._allToolsObs.read(reader); const toolSets = this._allToolSetsObs.read(reader); const enablement = this._toolSetEnablementService.observe(sessionType).read(reader); - const isCopilotSession = isCopilotCliSessionType(sessionType); + const isCopilotSession = isCopilotAgentHostSessionType(sessionType); const semanticSearchEnabled = isCopilotSession && this._semanticSearchEnabled.read(reader); const semanticSearchTool = isCopilotSession ? tools.find(tool => tool.id === CLIENT_SEMANTIC_SEARCH_TOOL_ID) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index c2d81421f04b..ba0aed8535e0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -34,6 +34,7 @@ import { IModelService } from '../../../../../../editor/common/services/model.js import { localize } from '../../../../../../nls.js'; import { AgentHostAllowSignedOutWhenUsableSettingId, AgentHostMcpToolRoutingEnabledSettingId, AgentProvider, AgentSession, CODEX_AGENT_PROVIDER_ID, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { agentHostAuthority, LOCAL_AGENT_HOST_AUTHORITY } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { isCopilotAgentHostProvider, isCopilotAgentHostSessionType } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; import { isCustomizationEnabled } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { findDeepestContainingWorkingDirectory } from '../../../../../../platform/agentHost/common/agentHostWorkingDirectories.js'; import { AgentHostElementAttachmentDisplayKind, getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js'; @@ -92,7 +93,7 @@ import { coerceImageBuffer } from '../../../common/chatImageExtraction.js'; import { ChatErrorLevel, ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestionAnswers, IChatService, IChatToolInvocation, IRemotePendingRequest, ToolConfirmKind, type IChatAutoModeResolutionPart, type IChatMcpAuthenticationRequired, type IChatMcpAuthenticationRequiredServer, type IChatMcpStartingServer, type IChatMultiSelectAnswer, type IChatPlanReviewResult, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData, type IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; import { isInConversationModelChoice } from '../../../common/modelSelection.js'; import { readAgentRuntimeModelConfiguration } from '../../../../../../platform/agentHost/common/meta/agentModelConfigurationMeta.js'; -import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, isTerminalCommandPrompt, SessionType, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; +import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, isTerminalCommandPrompt, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IWorkingCopyService } from '../../../../../services/workingCopy/common/workingCopyService.js'; import { ChatMode } from '../../../common/chatModes.js'; @@ -125,7 +126,6 @@ import { IChatResponseFileChangesService } from '../../chatResponseFileChangesSe import { AgentHostSessionReferenceAttachmentDisplayKind, AgentHostSessionReferenceTrajectoryAttachmentDisplayKind, toSessionReferenceAttachmentMeta, toSessionReferenceModelRepresentation } from './agentHostSessionReferenceAttachment.js'; import { buildHostLocalEventsPath } from '../../copilotCliEventsUri.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; -import { isCopilotCliSessionType } from './agentHostToolSetEnablementService.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; import { IAgentHostImportConversationStore } from './agentHostImportConversationStore.js'; import { activeTurnToProgress, appendToolOutput, BOOLEAN_TRUE_OPTION_ID, canOwnSubagentChat, completedToolCallToEditParts, completedToolCallToSerialized, containsAutomaticReplyAnswer, convertProtocolAnswers, convertProtocolPlanReviewResult, createInputRequestCarousel, createInputRequestPlanReview, finalizeToolInvocation, formatTurnResponseDetails, getAgentHostActivityProgressId, getTerminalContent, getUrlInputRequestPresentation, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToRequestOrigin, messageToRequestSource, messageToVariableData, parseAhpTerminalToolSessionId, rewriteAgentHostLinkTarget, shouldObserveSubagentChat, stringOrMarkdownToString, systemNotificationToChatPart, toolCallAuthenticationServer, toolCallStateToInvocation, toolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, turnsToHistory, turnToResponseDetails, updateRunningToolSpecificData, updateStreamingToolInvocation, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, type IAgentHostToolInvocationOptions, type ITurnModelInfo, type TurnModelLookup } from './stateToProgressAdapter.js'; @@ -2944,7 +2944,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** The workbench tool a runtime client-tool call maps to, or `undefined` when it is not installed. */ private _resolveClientTool(toolName: string): IToolData | undefined { - const isCopilotSession = isCopilotCliSessionType(this._config.sessionType); + const isCopilotSession = isCopilotAgentHostSessionType(this._config.sessionType); if (isCopilotSession && toolName === SEMANTIC_SEARCH_TOOL_NAME) { return this._toolsService.getTool(CLIENT_SEMANTIC_SEARCH_TOOL_ID); } @@ -6620,14 +6620,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return `${uri}#${start.line}:${start.character}-${end.line}:${end.character}`; } - /** - * Whether this backend reads referenced files from disk (rather than seeing the editor's - * in-memory buffer) and therefore needs the live text of an unsaved / dirty editor inlined as - * an embedded resource. Copilot CLI and Codex both run as separate processes with only disk - * access, so a `@path` mention (or an `untitled:` URI) would give them stale or missing content. - */ + /** Backends that read files from disk need unsaved editor content embedded in attachments. */ private _backendInlinesUnsavedEditors(): boolean { - return this._config.provider === SessionType.CopilotCLI || this._config.provider === CODEX_AGENT_PROVIDER_ID; + return isCopilotAgentHostProvider(this._config.provider) || this._config.provider === CODEX_AGENT_PROVIDER_ID; } /** A resource is unsaved when it's untitled or a saved file with in-memory (dirty) changes. */ @@ -6717,7 +6712,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private _convertVariableToAttachment(v: IChatRequestVariableEntry, sessionResource: URI, messageText: string | undefined, materializePastes = true): MessageAttachment | MessageAttachment[] | undefined { const referenceRange = this._toAttachmentReferenceRange(messageText, v.range); - // Copilot CLI and Codex can't read unsaved content from disk, so inline the live buffer; drop unreadable schemes. + // Copilot agents and Codex can't read unsaved content from disk, so inline the live buffer; drop unreadable schemes. if ((v.kind === 'file' || v.kind === 'implicit') && this._backendInlinesUnsavedEditors()) { const uri = isLocation(v.value) ? v.value.uri : (v.value instanceof URI ? v.value : undefined); if (uri && this._isUnsavedResource(uri)) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 043ee74700d7..836c890001d4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -47,7 +47,7 @@ import { IMarkdownRenderer } from '../../../../../platform/markdown/browser/mark import { isDark } from '../../../../../platform/theme/common/theme.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { parseRemoteAgentHostSessionTypeAuthority } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; +import { isCopilotAgentHostSessionType } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; import { isCreateChatTool, isCreateSessionTool, isSendMessageTool } from '../../../../../platform/agentHost/common/openSessionLink.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { CodiconActionViewItem } from '../../../notebook/browser/view/cellParts/cellActionView.js'; @@ -61,7 +61,7 @@ import { TerminalToolId } from '../../common/tools/terminalToolIds.js'; import { ChatAgentVoteDirection, ChatErrorLevel, ChatRequestQueueKind, ElicitationState, IChatConfirmation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatExternalEdit, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPlanReview, IChatPlanReviewResult, IChatPullRequestContent, IChatQuestionAnswerValue, IChatQuestionAnswers, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsageModelTotal, isChatFollowup } from '../../common/chatService/chatService.js'; import { ChatPlanReviewData } from '../../common/model/chatProgressTypes/chatPlanReviewData.js'; import { ChatQuestionCarouselData } from '../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; -import { localChatSessionType, SessionType } from '../../common/chatSessionsService.js'; +import { localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { getExplicitFileOrImageAttachmentSummary, IChatRequestVariableEntry, isExplicitFileOrImageVariableEntry, isPasteVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { getStickyScrollTargetItem, IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWorkingProgress, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM, IChatTurnPillsPart } from '../../common/model/chatViewModel.js'; @@ -136,7 +136,7 @@ import { IWorkbenchEnvironmentService } from '../../../../services/environment/c import { AccessibilityWorkbenchSettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { isActiveBackgroundTerminalToolInvocation, isAskQuestionsToolInvocation, isCarouselToolConfirmation, isMcpToolInvocation } from './chatContentParts/toolInvocationParts/chatToolPartUtilities.js'; import { isToolResultInputOutputDetails } from '../../common/tools/languageModelToolsService.js'; -import { AgentSessionProviders, isAgentHostTarget } from '../agentSessions/agentSessions.js'; +import { isAgentHostTarget } from '../agentSessions/agentSessions.js'; const $ = dom.$; @@ -681,16 +681,11 @@ const mostRecentResponseClassName = 'chat-most-recent-response'; export function shouldHideChatUserIdentity(username: string, sessionResource: URI, isResponse: boolean, isSessionsWindow: boolean, isSystemInitiatedRequest: boolean): boolean { const sessionType = getChatSessionType(sessionResource); return username === COPILOT_USERNAME || - (isResponse && isAgentHostCopilotSessionType(sessionType)) || + (isResponse && isCopilotAgentHostSessionType(sessionType)) || isSessionsWindow || isSystemInitiatedRequest; } -function isAgentHostCopilotSessionType(sessionType: string): boolean { - return sessionType === AgentSessionProviders.AgentHostCopilot || - parseRemoteAgentHostSessionTypeAuthority(sessionType, SessionType.CopilotCLI) !== undefined; -} - function upvoteAnimationSettingToEnum(value: string | undefined): ClickAnimation | undefined { switch (value) { case 'confetti': return ClickAnimation.Confetti; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts index 4b5756c3187f..75bfd18f4c7f 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts @@ -15,7 +15,7 @@ import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constan import { COPILOT_VENDOR_ID, ILanguageModelsService } from '../languageModels.js'; import { chatSessionResourceToId, getChatSessionType } from '../model/chatUri.js'; import { getAgentHostProviderForTelemetry, IChatSessionsService, isAgentHostSessionResource } from '../chatSessionsService.js'; -import { isRemoteAgentHostSessionType, parseRemoteAgentHostHarness } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; +import { isCopilotAgentHostProvider, isRemoteAgentHostSessionType, parseRemoteAgentHostHarness } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { isByokModel } from '../chatSelectedModel.js'; @@ -378,7 +378,7 @@ export class ChatRequestTelemetry { : undefined; this.selectedModelSource = !metadata ? 'unknown' : isByokModel(metadata) ? 'byok' - : metadata.vendor === COPILOT_VENDOR_ID || modelProvider === 'copilotcli' ? 'copilot' + : metadata.vendor === COPILOT_VENDOR_ID || isCopilotAgentHostProvider(modelProvider) ? 'copilot' : metadata.targetChatSessionType && (!modelProvider || modelProvider === 'unknown') ? 'unknown' : 'other'; } diff --git a/src/vs/workbench/contrib/chat/common/editorChatUsage.ts b/src/vs/workbench/contrib/chat/common/editorChatUsage.ts index e3cf761a2789..664e8751807e 100644 --- a/src/vs/workbench/contrib/chat/common/editorChatUsage.ts +++ b/src/vs/workbench/contrib/chat/common/editorChatUsage.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { parseRemoteAgentHostHarness } from '../../../../platform/agentHost/common/agentHostSessionType.js'; +import { isCopilotAgentHostProvider, parseRemoteAgentHostHarness } from '../../../../platform/agentHost/common/agentHostSessionType.js'; import { SessionType } from './chatSessionsService.js'; const storagePrefix = 'chat.editorUsage.'; @@ -29,8 +29,11 @@ function getProvider(sessionType: string): typeof providers[number] { case SessionType.Codex: return 'codexExtension'; case SessionType.Growth: return 'growth'; } - switch (parseRemoteAgentHostHarness(sessionType)) { - case 'copilotcli': return 'remoteCopilot'; + const remoteProvider = parseRemoteAgentHostHarness(sessionType); + if (isCopilotAgentHostProvider(remoteProvider)) { + return 'remoteCopilot'; + } + switch (remoteProvider) { case 'claude': return 'remoteClaude'; case 'codex': return 'remoteCodex'; default: return 'other'; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index c40730b8a412..21ec26032edc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -11436,6 +11436,56 @@ suite('AgentHostChatContribution', () => { suite('attachment context', () => { + for (const implicit of [false, true]) { + for (const { name, uri, dirty, text, expected } of [ + { name: 'untitled editor', uri: URI.from({ scheme: Schemas.untitled, path: '/Untitled-1' }), dirty: false, text: 'draft', expected: 'embedded' }, + { name: 'dirty saved editor', uri: URI.file('/workspace/test.ts'), dirty: true, text: 'edited but not saved', expected: 'embedded' }, + { name: 'clean saved editor', uri: URI.file('/workspace/test.ts'), dirty: false, text: 'saved', expected: 'resource' }, + { name: 'oversized untitled editor', uri: URI.from({ scheme: Schemas.untitled, path: '/Untitled-1' }), dirty: false, text: 'x'.repeat(1024 * 1024 + 1), expected: 'none' }, + { name: 'oversized dirty saved editor', uri: URI.file('/workspace/test.ts'), dirty: true, text: 'x'.repeat(1024 * 1024 + 1), expected: 'resource' }, + ]) { + test(`sandbox Copilot handles ${implicit ? 'implicit' : 'explicit'} ${name} attachments`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { instantiationService, agentHostService, chatAgentService, chatWidgetService, modelService, workingCopyService } = createTestServices(disposables); + const sessionType = 'remote-cloudsandbox_environment-one-copilot'; + const sessionResource = URI.from({ scheme: sessionType, path: '/session-one' }); + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot', + backendSessionScheme: 'ahp-session', + agentId: 'agent-host-copilot', + sessionType, + fullName: 'Copilot [Sandbox session title]', + description: 'Sandbox Copilot', + connection: agentHostService, + connectionAuthority: 'cloudsandbox_environment-one', + })); + modelService.setModelContent(uri, text); + workingCopyService.setDirty(uri, dirty); + if (implicit) { + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'test', isSelection: false, uri, value: uri }, + ]); + } + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this file', + sessionResource, + variables: { variables: implicit ? [] : [{ kind: 'file', id: 'v-file', name: 'test', value: uri }] }, + }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; + + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual({ session, attachments: turnAction.message.attachments }, { + session: buildDefaultChatUri(AgentSession.uri('ahp-session', 'session-one').toString()), + attachments: expected === 'embedded' + ? [{ type: MessageAttachmentKind.EmbeddedResource, label: 'test', displayKind: 'document', data: encodeBase64(VSBuffer.fromString(text)), contentType: 'text/plain' }] + : expected === 'resource' + ? [{ type: MessageAttachmentKind.Resource, uri: uri.toString(), label: 'test', displayKind: 'document' }] + : undefined, + }); + })); + } + } + test('file variable with file:// URI becomes file attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -12167,8 +12217,8 @@ suite('AgentHostChatContribution', () => { ]); })); - test('active editor implicit context is not forwarded for untitled editors on non-Copilot-CLI backends', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); + test('active editor implicit context is not forwarded for untitled editors on non-Copilot backends', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables, { provider: 'claude' }); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-untitled' }); const untitledUri = URI.from({ scheme: 'untitled', path: '/Untitled-1' }); chatWidgetService.setWidgetForSession(sessionResource, [ @@ -12327,7 +12377,7 @@ suite('AgentHostChatContribution', () => { })); test('non-file URI variables (e.g. untitled documents) are forwarded as attachments', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { provider: 'claude' }); const uri = URI.from({ scheme: 'untitled', path: '/foo' }); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { @@ -12447,29 +12497,30 @@ suite('AgentHostChatContribution', () => { ]); })); - test('inlined unsaved attachment preserves _meta and selection for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService, chatAgentService, modelService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); - const fileUri = URI.file('/workspace/foo.ts'); - modelService.setModelContent(fileUri, 'first line\nsecond line\nthird line\nfourth line content'); - workingCopyService.setDirty(fileUri, true); + for (const provider of ['copilotcli', 'copilot', 'codex']) { + test(`inlined unsaved attachment preserves _meta and selection for ${provider}`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, modelService, workingCopyService } = createContribution(disposables, { provider }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'first line\nsecond line\nthird line\nfourth line content'); + workingCopyService.setDirty(fileUri, true); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { - message: 'check this', - variables: { - variables: [ - upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: { uri: fileUri, range: new Range(2, 1, 4, 10) }, _meta: { provider: 'fs', score: 0.42 } }), - ], - }, - }); - fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); - await turnPromise; + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: { uri: fileUri, range: new Range(2, 1, 4, 10) }, _meta: { provider: 'fs', score: 0.42 } }), + ], + }, + }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; - assert.strictEqual(agentHostService.turnActions.length, 1); - const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.message.attachments, [ - { type: MessageAttachmentKind.EmbeddedResource, label: 'foo.ts', displayKind: 'selection', data: encodeBase64(VSBuffer.fromString('second line\nthird line\nfourth li')), contentType: 'text/plain', selection: { range: { start: { line: 1, character: 0 }, end: { line: 3, character: 9 } } }, _meta: { provider: 'fs', score: 0.42 } }, - ]); - })); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'foo.ts', displayKind: 'selection', data: encodeBase64(VSBuffer.fromString('second line\nthird line\nfourth li')), contentType: 'text/plain', selection: { range: { start: { line: 1, character: 0 }, end: { line: 3, character: 9 } } }, _meta: { provider: 'fs', score: 0.42 } }, + ]); + })); + } test('dirty non-file resource that cannot be inlined is dropped for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 978c82be91df..6587886f2e4f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -89,6 +89,7 @@ suite('AgentHostClientTools', () => { /** A remote agent host running the same Copilot CLI harness (`remote-{authority}-{provider}`). */ const REMOTE_COPILOT_CLI_SESSION_TYPE = 'remote-devbox-copilotcli'; + const CLOUD_SANDBOX_SESSION_TYPE = 'remote-cloudsandbox_environment-one-copilot'; const disposables = new DisposableStore(); @@ -348,12 +349,16 @@ suite('AgentHostClientTools', () => { localDisabled: await publishedTools(tools, AGENT_HOST_COPILOT_CLI_SESSION_TYPE, false), localEnabled: await publishedTools(tools, AGENT_HOST_COPILOT_CLI_SESSION_TYPE, true), remoteEnabled: await publishedTools(tools, REMOTE_COPILOT_CLI_SESSION_TYPE, true), + sandboxDisabled: await publishedTools(tools, CLOUD_SANDBOX_SESSION_TYPE, false), + sandboxEnabled: await publishedTools(tools, CLOUD_SANDBOX_SESSION_TYPE, true), otherEnabled: await publishedTools(tools, 'agent-host-claude', true), withoutCanonical: await publishedTools([collidingCodebaseTool, collidingSemanticSearchTool, readFileTool], AGENT_HOST_COPILOT_CLI_SESSION_TYPE, true), }, { localDisabled: [['readFile', 'Read File']], localEnabled: [[SEMANTIC_SEARCH_TOOL_NAME, 'Search Codebase'], ['readFile', 'Read File']], remoteEnabled: [[SEMANTIC_SEARCH_TOOL_NAME, 'Search Codebase'], ['readFile', 'Read File']], + sandboxDisabled: [['readFile', 'Read File']], + sandboxEnabled: [[SEMANTIC_SEARCH_TOOL_NAME, 'Search Codebase'], ['readFile', 'Read File']], otherEnabled: [[CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME, 'Other Codebase'], [SEMANTIC_SEARCH_TOOL_NAME, 'Other Semantic Search'], ['readFile', 'Read File']], withoutCanonical: [['readFile', 'Read File']], }); @@ -2749,7 +2754,7 @@ suite('AgentHostClientTools', () => { test('maps semantic search to codebase only for Copilot sessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const invoke = async (sessionType: string, toolCallId: string) => { - const isCopilot = sessionType === AGENT_HOST_COPILOT_CLI_SESSION_TYPE || sessionType === REMOTE_COPILOT_CLI_SESSION_TYPE; + const isCopilot = sessionType === AGENT_HOST_COPILOT_CLI_SESSION_TYPE || sessionType === REMOTE_COPILOT_CLI_SESSION_TYPE || sessionType === CLOUD_SANDBOX_SESSION_TYPE; const codebaseTool = isCopilot ? semanticSearchTool : { ...semanticSearchTool, canRequestPreApproval: true }; @@ -2792,9 +2797,10 @@ suite('AgentHostClientTools', () => { [ await invoke(AGENT_HOST_COPILOT_CLI_SESSION_TYPE, 'copilot-semantic'), await invoke(REMOTE_COPILOT_CLI_SESSION_TYPE, 'remote-copilot-semantic'), + await invoke(CLOUD_SANDBOX_SESSION_TYPE, 'sandbox-copilot-semantic'), await invoke('agent-host-claude', 'claude-semantic'), ], - [semanticSearchTool.id, semanticSearchTool.id, collidingSemanticSearchTool.id], + [semanticSearchTool.id, semanticSearchTool.id, semanticSearchTool.id, collidingSemanticSearchTool.id], ); })); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index a35b1327d52b..0a91d7fa6f83 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -20,6 +20,9 @@ import { Range } from '../../../../../../editor/common/core/range.js'; import { ICodeEditorService } from '../../../../../../editor/browser/services/codeEditorService.js'; import { IActionViewItemFactory, IActionViewItemService, NullActionViewItemService } from '../../../../../../platform/actions/browser/actionViewItemService.js'; import { IMenu, IMenuService, MenuId, MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; +import { agentHostAuthority } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { remoteAgentHostSessionTypeId } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; +import { CLOUD_SANDBOX_AGENT_PROVIDER, cloudSandboxAddress } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { ConfirmationOptionKind, McpServerStatus, ToolCallStatus } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; @@ -1213,6 +1216,20 @@ suite('ChatListRenderer', () => { })); suite('shouldHideChatUserIdentity', () => { + test('hides cloud sandbox response identity without hiding user identity', () => { + const sessionResource = URI.from({ + scheme: remoteAgentHostSessionTypeId(agentHostAuthority(cloudSandboxAddress('environment-one')), CLOUD_SANDBOX_AGENT_PROVIDER), + path: '/session-one', + }); + assert.deepStrictEqual([ + shouldHideChatUserIdentity('Copilot [Fix the issue with a long session title]', sessionResource, true, false, false), + shouldHideChatUserIdentity('Copilot [Renamed session]', sessionResource, true, false, false), + shouldHideChatUserIdentity('User', sessionResource, false, false, false), + shouldHideChatUserIdentity('Copilot [Remote host]', URI.from({ scheme: 'remote-test-authority-copilot' }), true, false, false), + shouldHideChatUserIdentity('User', URI.from({ scheme: 'remote-test-authority-copilot' }), false, false, false), + ], [true, true, false, true, false]); + }); + test('hides local Copilot and Agent Host Copilot response identity', () => { assert.deepStrictEqual([ shouldHideChatUserIdentity('GitHub Copilot', URI.from({ scheme: 'vscode-chat-editor' }), true, false, false), diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatRequestTelemetry.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatRequestTelemetry.test.ts index 0cca7d1c1533..d2978cf82d32 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatRequestTelemetry.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatRequestTelemetry.test.ts @@ -31,8 +31,9 @@ suite('ChatRequestTelemetry request context', () => { instantiationService.stub(IChatSessionsService, new class extends mock() { override getChatSessionContribution(sessionType: string) { const provider = sessionType === 'agent-host-copilotcli' ? 'copilotcli' - : sessionType === 'agent-host-claude' ? 'claude' - : sessionType === 'agent-host-unresolved' ? 'unknown' : undefined; + : sessionType === 'remote-cloudsandbox_environment-one-copilot' ? 'copilot' + : sessionType === 'agent-host-claude' ? 'claude' + : sessionType === 'agent-host-unresolved' ? 'unknown' : undefined; return provider ? { type: sessionType, name: 'Test', displayName: 'Test', description: 'Test', agentHostProviderId: provider, @@ -114,6 +115,8 @@ suite('ChatRequestTelemetry request context', () => { { name: 'local Copilot', metadata: model, expected: 'copilot' }, { name: 'local BYOK', metadata: { ...model, isBYOK: true }, expected: 'byok' }, { name: 'host Copilot', metadata: { ...model, vendor: 'host', targetChatSessionType: 'agent-host-copilotcli' }, expected: 'copilot' }, + { name: 'sandbox Copilot', metadata: { ...model, vendor: 'host', targetChatSessionType: 'remote-cloudsandbox_environment-one-copilot' }, expected: 'copilot' }, + { name: 'sandbox BYOK bridge', metadata: { ...model, vendor: 'host', targetChatSessionType: 'remote-cloudsandbox_environment-one-copilot', byokModelIdentifier: 'private/model' }, expected: 'byok' }, { name: 'known non-Copilot host provider', metadata: { ...model, vendor: 'host', targetChatSessionType: 'agent-host-claude' }, expected: 'other' }, { name: 'explicitly unknown host provider', metadata: { ...model, vendor: 'host', targetChatSessionType: 'agent-host-unresolved' }, expected: 'unknown' }, { name: 'host BYOK bridge', metadata: { ...model, vendor: 'host', targetChatSessionType: 'agent-host-copilotcli', byokModelIdentifier: 'private/model' }, expected: 'byok' }, diff --git a/src/vs/workbench/contrib/chat/test/common/editorChatUsage.test.ts b/src/vs/workbench/contrib/chat/test/common/editorChatUsage.test.ts index ea801ff6d78f..ef786e2224ab 100644 --- a/src/vs/workbench/contrib/chat/test/common/editorChatUsage.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/editorChatUsage.test.ts @@ -83,12 +83,12 @@ suite('EditorChatUsage', () => { test('normalizes remote authorities and unknown providers, and clamps clock skew', () => { const storage = disposables.add(new InMemoryStorageService()); const usage = new EditorChatUsage(storage); - for (const provider of ['remote-private-host-copilotcli', 'remote-private-host-claude', 'remote-private-host-codex', 'private-extension']) { + for (const provider of ['remote-private-host-copilotcli', 'remote-cloudsandbox_private-environment-copilot', 'remote-private-host-claude', 'remote-private-host-codex', 'private-extension']) { usage.recordSubmission(provider, true, false, false, 5000); } assert.deepStrictEqual(usage.getTelemetry(4000), { - editorSessionsByProvider: '{"remoteCopilot":1,"remoteClaude":1,"remoteCodex":1,"other":1}', - editorMessages: 4, + editorSessionsByProvider: '{"remoteCopilot":2,"remoteClaude":1,"remoteCodex":1,"other":1}', + editorMessages: 5, editorMessagesWithOtherSessionInProgress: 0, editorMessagesWithOtherSessionInProgressAcrossWindows: 0, editorLastMessageSecondsAgo: 0, From 548a0c478266b449e1279b0696d7a0ee18693e9f Mon Sep 17 00:00:00 2001 From: roblourens Date: Sun, 4 Oct 2026 17:44:08 -0700 Subject: [PATCH 30/49] sessions: Use first-prompt titles for Cloud Sandboxes (#339620) * sessions: Use first-prompt titles for Cloud Sandboxes Push the draft title to the sandbox host before sending the first turn so new sessions do not retain the default branch name. Cover both creation paths and title normalization in regression tests. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Retain sandbox titles until the host knows the session Live sandbox validation reproduced the early rename being lost before host session creation. Retain provisional renames and apply the latest title when the host lists or announces the session. Cover delayed host discovery, replacement renames, and cleanup in real-provider regression tests. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/copilotChatSessionsProvider.ts | 4 +- .../copilotChatSessionsProvider.test.ts | 45 +++++- .../browser/cloudSandboxSessionsProvider.ts | 41 +++++- .../remoteAgentHostSessionsProvider.test.ts | 139 +++++++++++++++++- 4 files changed, 218 insertions(+), 11 deletions(-) diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 5c2d1bfdabcf..3131a7466b89 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1716,7 +1716,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._sandboxSends.add(session.sessionId); const preparation = session.startPreparation(); const token = preparation.object; - session.setTitle((options.title || options.query.split('\n')[0]).substring(0, 100) || localize('new session', "New Session")); + session.setTitle((options.title || options.query.split('\n')[0]).trim().substring(0, 100) || localize('new session', "New Session")); session.setStatus(SessionStatus.InProgress); this._sessionCache.set(session.resource.toString(), session); const placeholder = this._chatToSession(session); @@ -1754,6 +1754,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // *additional* peer chat inside a session that already has one. const chat = provisioned.session.mainChat.get(); await raceCancellationError(this._carryModelToSandbox(provisioned, chat.resource, selectedModel), token); + // Preserve the prompt-derived title rather than the host's branch-name default. + await raceCancellationError(provisioned.provider.renameSession(provisioned.session.sessionId, session.title.get()), token); const committed = await provisioned.provider.sendRequest(provisioned.session.sessionId, chat.resource, options); // Retire only once the turn is dispatched; swapping earlier bounces the view home. diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index ab69124b2954..8a244e959cfc 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -2565,17 +2565,21 @@ suite('CopilotChatSessionsProvider', () => { * resolution reports `pending` until it yields the model, mirroring an agent host that has * connected but not yet published. */ - function provisionedSession(sendRequest?: CloudSandboxSessionsProvider['sendRequest'], sandboxModels: () => readonly ILanguageModelChatMetadataAndIdentifier[] = () => []): ICloudSandboxProvisionedSession & { published: string[]; modelSelections: { modelId: string; source: ChatModelSource }[]; modelsChanged: Emitter } { + function provisionedSession(sendRequest?: CloudSandboxSessionsProvider['sendRequest'], sandboxModels: () => readonly ILanguageModelChatMetadataAndIdentifier[] = () => []): ICloudSandboxProvisionedSession & { published: string[]; renames: { sessionId: string; title: string }[]; modelSelections: { modelId: string; source: ChatModelSource }[]; modelsChanged: Emitter } { + const title = observableValue('title', 'main'); const committed = upcastPartial({ sessionId: 'agenthost:sess-new', resource: URI.parse('agent-host-copilot:/sess-new'), + title, }); const sandboxSession = upcastPartial({ sessionId: 'agenthost:sess-new', resource: URI.parse('agent-host-copilot:/sess-new'), + title, mainChat: constObservable(upcastPartial({ resource: URI.parse('agent-host-copilot:/sess-new') })), }); const published: string[] = []; + const renames: { sessionId: string; title: string }[] = []; const modelSelections: { modelId: string; source: ChatModelSource }[] = []; const modelsChanged = disposables.add(new Emitter()); return { @@ -2584,10 +2588,15 @@ suite('CopilotChatSessionsProvider', () => { environmentId: 'env-new', session: sandboxSession, published, + renames, modelSelections, modelsChanged, provider: upcastPartial({ sendRequest: sendRequest ?? (async () => committed), + renameSession: async (sessionId, newTitle) => { + renames.push({ sessionId, title: newTitle }); + title.set(newTitle, undefined); + }, publishWithheldSession: (rawId: string) => { published.push(rawId); }, onDidChangeModels: modelsChanged.event, getModelsSnapshot: (_sessionId: string, desiredModelId?: string) => { @@ -2630,6 +2639,40 @@ suite('CopilotChatSessionsProvider', () => { } for (const providerMode of ['default', 'sandbox'] as const) { + for (const { name, options, expected } of [ + { name: 'first prompt', options: { query: 'Fix the login bug' }, expected: 'Fix the login bug' }, + { name: 'multiline prompt', options: { query: 'Fix the login bug\nHere are the details' }, expected: 'Fix the login bug' }, + { name: 'surrounding whitespace', options: { query: ' Fix the login bug \nHere are the details' }, expected: 'Fix the login bug' }, + { name: 'long prompt', options: { query: 'x'.repeat(101) }, expected: 'x'.repeat(100) }, + { name: 'explicit title', options: { query: 'Fix the login bug', title: 'Login fix' }, expected: 'Login fix' }, + { name: 'empty first line', options: { query: '\nFix the login bug' }, expected: 'New Session' }, + { name: 'whitespace first line', options: { query: ' \nFix the login bug' }, expected: 'New Session' }, + ]) { + test(`${providerMode} creation pushes the ${name} title to the sandbox before sending`, async () => { + let titleAtSend: string | undefined; + const provisioned = provisionedSession(async () => { + titleAtSend = provisioned.session.title.get(); + return provisioned.session; + }); + const { provider } = createSandboxProvider({ providerMode, provision: async () => provisioned }); + const sessionType = providerMode === 'sandbox' ? CopilotSandboxSessionType.id : CopilotCloudSessionType.id; + const draft = provider.createNewSession(repoWorkspace, sessionType); + const session = provider.getSession(draft.sessionId)!; + session.setUseSandbox(true); + + const committed = await provider.sendRequest(draft.sessionId, draft.mainChat.get().resource, options); + + assert.deepStrictEqual({ + renames: provisioned.renames, + titleAtSend, + committedTitle: committed.title.get(), + }, { + renames: [{ sessionId: 'agenthost:sess-new', title: expected }], + titleAtSend: expected, + committedTitle: expected, + }); + }); + } test(`${providerMode} sandbox startup uses draft preparation progress without registering a chat provider or changing its resource`, async () => { const pending = new DeferredPromise(); const preparing = new DeferredPromise(); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts index b54483c440de..4700544793e5 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts @@ -13,6 +13,7 @@ import { localize } from '../../../../../nls.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; import { StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import type { ISession } from '../../../../services/sessions/common/session.js'; +import type { AgentHostSessionAdapter } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; /** @@ -48,6 +49,7 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide * waking a sandbox can take minutes. */ private readonly _provisionalSessions = new Map(); + private readonly _pendingSessionTitles = new Map(); /** How long a provisional session resists eviction after the host first omits it. */ static readonly PROVISIONAL_GRACE_MS = 2 * 60_000; @@ -74,6 +76,27 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide return `sessions.cloudSandbox.localSession.${this.id}.${rawId}`; } + protected override updateAdapter(adapter: AgentHostSessionAdapter, meta: IAgentSessionMetadata): boolean { + const changed = super.updateAdapter(adapter, meta); + const rawId = AgentSession.id(meta.session); + // Unlike discovery seeds, this metadata comes from the host's listing or session-added notification. + this._provisionalSessions.delete(rawId); + const title = this._pendingSessionTitles.get(rawId); + if (title !== undefined && this.connection) { + this._pendingSessionTitles.delete(rawId); + void super.renameSession(adapter.sessionId, title).catch(error => { + this._logService.error(`[CloudSandboxSessionsProvider] Failed to apply initial title for ${rawId}`, error); + }); + return true; + } + return changed; + } + + protected override _onBackendSessionRemoved(rawId: string): void { + super._onBackendSessionRemoved(rawId); + this._pendingSessionTitles.delete(rawId); + } + protected override _resolveArchivedState(rawId: string, isArchived: boolean): boolean { return this._taskArchiveHandler?.rawId === rawId ? this._sessionCache.get(rawId)?.isArchived.get() ?? isArchived @@ -98,16 +121,18 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide } const handler = this._taskRenameHandler; if (handler?.rawId !== rawId) { - if (!this.connection) { + if (!this.connection && !this._provisionalSessions.has(rawId)) { throw new Error(localize('cloudSandbox.renameUnavailable', "Connect to the environment to rename this session.")); } - return super.renameSession(sessionId, title); + } else { + await handler.rename(title); + if (this._store.isDisposed || this._sessionCache.get(rawId) !== session) { + throw new CancellationError(); + } } - await handler.rename(title); - if (this._store.isDisposed || this._sessionCache.get(rawId) !== session) { - throw new CancellationError(); - } - if (this.connection) { + if (this._provisionalSessions.has(rawId)) { + this._pendingSessionTitles.set(rawId, title); + } else if (this.connection) { return super.renameSession(sessionId, title); } session.title.set(title, undefined); @@ -210,6 +235,7 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide const session = this._removeCachedSession(rawId); this._withheldSessions.delete(rawId); this._provisionalSessions.delete(rawId); + this._pendingSessionTitles.delete(rawId); if (session) { this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); session.dispose(); @@ -232,6 +258,7 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide return false; } this._provisionalSessions.delete(rawId); + this._pendingSessionTitles.delete(rawId); return true; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 4e49dadd7f64..a11e6e5bdf5b 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -4074,7 +4074,7 @@ suite('CloudSandboxSessionsProvider renaming', () => { refresh(): Promise { return this._refreshSessions(); } } - function createSandbox(): { provider: TestSandboxProvider; connection: MockAgentConnection; renamed: string[] } { + function createSandbox(provisional = false): { provider: TestSandboxProvider; connection: MockAgentConnection; renamed: string[] } { const connection = store.add(new MockAgentConnection()); connection.addSession({ ...metadata, session: backendUri }); const provider = createProvider(store.add(new DisposableStore()), connection, { @@ -4083,7 +4083,11 @@ suite('CloudSandboxSessionsProvider renaming', () => { }) as TestSandboxProvider; const renamed: string[] = []; provider.setTaskRenameHandler('sandbox-session', async title => { renamed.push(title); }); - provider.seedSessions([metadata], { updateExisting: true }); + if (provisional) { + provider.seedProvisionalSession(metadata); + } else { + provider.seedSessions([metadata], { updateExisting: true }); + } return { provider, connection, renamed }; } @@ -4144,6 +4148,23 @@ suite('CloudSandboxSessionsProvider renaming', () => { assert.deepStrictEqual({ title: session.title.get(), dispatched: connection.dispatchedActions }, { title: 'Old title', dispatched: [] }); }); + test('does not queue a provisional rename rejected by Mission Control', async () => { + const { provider, connection } = createSandbox(true); + const session = provider.getCachedSession('sandbox-session')!; + provider.setTaskRenameHandler('sandbox-session', async () => { throw new Error('task rename rejected'); }); + await assert.rejects(provider.renameSession(session.sessionId, 'New title'), /task rename rejected/); + provider.setConnection(connection); + await provider.refresh(); + + assert.deepStrictEqual({ + title: session.title.get(), + dispatched: connection.dispatchedActions, + }, { + title: 'Old title', + dispatched: [], + }); + }); + test('does not update a session deleted while task rename is in flight', async () => { const { provider, connection } = createSandbox(); const pending = new DeferredPromise(); @@ -4661,6 +4682,120 @@ suite('CloudSandboxSessionsProvider provisional sessions', () => { await timeout(0); } + for (const source of ['session-added notification', 'host listing'] as const) { + test(`a provisional rename reaches the host after delayed creation via ${source}`, async () => { + const rawId = 'delayed-title'; + let hostTitle: string | undefined; + const rejectedTitles: string[] = []; + connection = new class extends MockAgentConnection { + override dispatch(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { + if (action.type === ActionType.SessionTitleChanged) { + if (hostTitle === undefined) { + rejectedTitles.push(action.title); + } else { + hostTitle = action.title; + this.addSession(createSession(rawId, { provider: 'ahp-session', summary: hostTitle })); + } + } + super.dispatch(channel, action); + } + }(); + const provider = createProvider(disposables, connection, { + ctor: CloudSandboxSessionsProvider, + sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, + noConnection: true, + }) as CloudSandboxSessionsProvider; + const metadata = createSession(rawId, { provider: 'copilot', summary: 'owner/repository' }); + provider.seedProvisionalSession(metadata); + const taskRenames: string[] = []; + provider.setTaskRenameHandler(rawId, async title => { taskRenames.push(title); }); + provider.setConnection(connection); + await timeout(0); + const session = provider.getCachedSession(rawId)!; + + await provider.renameSession(session.sessionId, 'Fix the login bug'); + provider.seedSessions([{ ...metadata, summary: 'Discovery title', modifiedTime: metadata.modifiedTime + 1 }], { updateExisting: true }); + const beforeHostCreation = { + localTitle: session.title.get(), + actions: [...connection.dispatchedActions], + taskRenames: [...taskRenames], + }; + + hostTitle = 'main'; + connection.addSession(createSession(rawId, { provider: 'ahp-session', summary: hostTitle })); + if (source === 'session-added notification') { + fireSessionAdded(connection, rawId, { provider: 'ahp-session', title: hostTitle }); + } else { + await refreshViaTurnComplete(connection, rawId); + } + await refreshViaTurnComplete(connection, rawId); + + assert.deepStrictEqual({ + beforeHostCreation, + localTitle: session.title.get(), + hostTitle, + rejectedTitles, + taskRenames, + actions: connection.dispatchedActions.map(({ channel, action }) => ({ channel, action })), + }, { + beforeHostCreation: { localTitle: 'Fix the login bug', actions: [], taskRenames: ['Fix the login bug'] }, + localTitle: 'Fix the login bug', + hostTitle: 'Fix the login bug', + rejectedTitles: [], + taskRenames: ['Fix the login bug'], + actions: [{ channel: `ahp-session:/${rawId}`, action: { type: ActionType.SessionTitleChanged, title: 'Fix the login bug' } }], + }); + }); + } + + test('a newer rename replaces the queued initial title before host creation', async () => { + const provider = createSandboxProvider(disposables, connection, { noConnection: true }); + provider.seedProvisionalSession(createSession('renamed-before-create')); + const session = provider.getCachedSession('renamed-before-create')!; + + await provider.renameSession(session.sessionId, 'First prompt'); + await provider.renameSession(session.sessionId, 'My chosen title'); + provider.setConnection(connection); + await timeout(0); + fireSessionAdded(connection, 'renamed-before-create', { title: 'main' }); + + assert.deepStrictEqual({ + title: session.title.get(), + actions: connection.dispatchedActions.map(({ action }) => action), + }, { + title: 'My chosen title', + actions: [{ type: ActionType.SessionTitleChanged, title: 'My chosen title' }], + }); + }); + + for (const removal of ['local deletion', 'host removal'] as const) { + test(`a queued title is discarded on ${removal}`, async () => { + const provider = createSandboxProvider(disposables, connection, { noConnection: true }); + const metadata = createSession('removed-before-create'); + provider.seedProvisionalSession(metadata); + const session = provider.getCachedSession('removed-before-create')!; + await provider.renameSession(session.sessionId, 'First prompt'); + provider.setConnection(connection); + await timeout(0); + + if (removal === 'local deletion') { + provider.removeDeletedSession('removed-before-create'); + } else { + fireSessionRemoved(connection, 'removed-before-create'); + } + provider.seedSessions([metadata]); + fireSessionAdded(connection, 'removed-before-create', { title: 'main' }); + + assert.deepStrictEqual({ + title: provider.getCachedSession('removed-before-create')?.title.get(), + actions: connection.dispatchedActions, + }, { + title: 'main', + actions: [], + }); + }); + } + test('a provisional session survives a host listing that does not know it yet', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // The first listing after connecting can legitimately omit a just-minted session. connection.addSession(createSession('other-1', { summary: 'Someone else' })); From 1828c0f07d13581ea1bf58aec83c7416bb38003c Mon Sep 17 00:00:00 2001 From: Paul <8560030+pwang347@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:56:48 -0700 Subject: [PATCH 31/49] Show connector names in MCP authentication (#339431) * Show connector names in MCP authentication Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address MCP connector review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Identify connector names in MCP authentication Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Publish connector labels during reconciliation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use connector labels across MCP surfaces Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refresh pending connector authentication labels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/meta/mcpCustomizationMeta.ts | 2 +- .../meta/vscode/mcpCustomizationMeta.ts | 21 ++++++ .../node/copilot/copilotAgentSession.ts | 15 ++++ .../node/copilot/copilotSessionLauncher.ts | 31 +++++--- .../node/shared/mcpCustomizationController.ts | 21 ++++-- .../test/common/agentMetaReaders.test.ts | 19 +++-- .../test/node/copilotAgentSession.test.ts | 32 +++++++- .../test/node/copilotSessionLauncher.test.ts | 14 +++- .../shared/mcpCustomizationController.test.ts | 16 ++-- .../common/agentHostSessionsProvider.ts | 2 + .../agentHostCustomizationService.ts | 12 ++- .../agentHost/agentHostSessionHandler.ts | 36 +++++++-- .../agentHost/stateToProgressAdapter.ts | 8 +- .../browser/aiCustomization/mcpListWidget.ts | 10 +-- .../browser/aiCustomization/mcpServerCount.ts | 16 +++- .../chatMcpAuthenticationContentPart.ts | 10 +-- .../chatMcpServersStartingContentPart.ts | 4 +- .../chatToolAuthenticationSubPart.ts | 8 +- .../agentHostCustomizationService.test.ts | 25 +++++-- .../stateToProgressAdapter.test.ts | 4 +- .../aiCustomization/mcpListWidget.test.ts | 19 +++++ .../chatMcpAuthenticationContentPart.test.ts | 23 +++++- .../chatMcpServersStartingContentPart.test.ts | 6 ++ .../chatToolAuthenticationSubPart.test.ts | 31 +++++++- .../chat/test/common/model/chatModel.test.ts | 5 +- .../contrib/mcp/browser/mcpCommands.ts | 11 +-- ...hatMcpAuthenticationContentPart.fixture.ts | 73 +++++++++++++++++++ 27 files changed, 388 insertions(+), 86 deletions(-) create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/chatMcpAuthenticationContentPart.fixture.ts diff --git a/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts index 682826484180..3d849ec889c8 100644 --- a/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts @@ -4,4 +4,4 @@ *--------------------------------------------------------------------------------------------*/ export type { McpServerSource } from './vscode/mcpCustomizationMeta.js'; -export { readMcpServerSource, withMcpServerSourceMeta } from './vscode/mcpCustomizationMeta.js'; +export { readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from './vscode/mcpCustomizationMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts index 257cf48a442c..5b854324187e 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts @@ -6,6 +6,8 @@ import type { McpServerCustomization } from '../../state/protocol/state.js'; const sourceKey = 'agentHost.mcpServerSource'; +const displayNameKey = 'vscode.mcpServerDisplayName'; +const maxDisplayNameLength = 512; export type McpServerSource = | 'user' // Defined in user-level configuration. @@ -36,3 +38,22 @@ export function withMcpServerSourceMeta(meta: Record | undefine } return { ...(meta ?? {}), [sourceKey]: source }; } + +/** Reads an optional Connector catalog name without changing the MCP server's runtime identity. */ +export function readMcpServerDisplayName(customization: McpServerCustomization | undefined): string | undefined { + return normalizeMcpServerDisplayName(customization?._meta?.[displayNameKey]); +} + +/** Records an optional Connector catalog name while preserving every other metadata entry. */ +export function withMcpServerDisplayNameMeta(meta: Record | undefined, displayName: string | undefined): Record | undefined { + const normalized = normalizeMcpServerDisplayName(displayName); + return normalized === undefined ? meta : { ...(meta ?? {}), [displayNameKey]: normalized }; +} + +function normalizeMcpServerDisplayName(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed.length <= maxDisplayNameLength ? trimmed : undefined; +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index ef1308366cbe..58e0577e2604 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1281,6 +1281,7 @@ export class CopilotAgentSession extends Disposable { /** One-shot SDK callbacks, keyed by request id; answering one delivers a token but does not confirm acceptance. */ private readonly _pendingMcpAuthRequests = new PendingRequestRegistry(); private _requiresConnectorConfigurationRefresh = false; + private readonly _mcpServerDisplayNames = new Map(); /** * Retains challenge metadata and its latest callback id so token delivery can report Starting. * Connected and needs-auth statuses remain the final lifecycle authority. @@ -3199,6 +3200,7 @@ export class CopilotAgentSession extends Disposable { handleExitPlanModeRequest: this._guarded((request, invocation) => this._handleExitPlanModeRequest(request, invocation), { approved: false } satisfies CopilotExitPlanModeResponse, 'exit-plan-mode'), handleUserInputRequest: this._guarded((request, invocation) => this._handleUserInputRequest(request, invocation), { answer: '', wasFreeform: true } satisfies UserInputResponse, 'user-input'), handleElicitationRequest: this._guarded(context => this._handleElicitationRequest(context), { action: 'cancel' } satisfies ElicitationResult, 'elicitation'), + setMcpServerDisplayNames: displayNames => this._setMcpServerDisplayNames(displayNames), handleMcpAuthRequest: this._guarded(request => this._handleMcpAuthRequest(request), { kind: 'cancelled' } satisfies McpAuthResult, 'mcp-auth'), requestUnsandboxedCommandConfirmation: this._guarded(request => this._requestUnsandboxedCommandConfirmation(request), false, 'unsandboxed-command-confirmation'), createClientSdkTools: toolSearchActive => this._createClientSdkTools(toolSearchActive), @@ -3210,6 +3212,17 @@ export class CopilotAgentSession extends Disposable { }; } + private _setMcpServerDisplayNames(displayNames: ReadonlyMap): void { + this._mcpServerDisplayNames.clear(); + for (const [serverName, displayName] of displayNames) { + this._mcpServerDisplayNames.set(serverName, displayName); + const state = this._mcpCustomizations.stateForServer(serverName); + if (state) { + this._mcpCustomizations.applyOne({ name: serverName, displayName, state }); + } + } + } + /** Resolves only matching, currently pending SDK authentication callbacks. */ async resolveMcpAuthentication(params: AuthenticateParams): Promise { let resolved = false; @@ -3293,6 +3306,7 @@ export class CopilotAgentSession extends Disposable { }); this._mcpCustomizations.applyOne({ name: request.serverName, + displayName: this._mcpServerDisplayNames.get(request.serverName), state: { kind: McpServerStatus.AuthRequired, ...auth, @@ -7927,6 +7941,7 @@ export class CopilotAgentSession extends Disposable { : {}; return { name: server.name, + displayName: this._mcpServerDisplayNames.get(server.name), state: this._translateSdkMcpStatus(server.name, server.status, server.error, hasPendingAuthentication), ...(server.status === 'pending' && !hasPendingAuthentication ? { allowAuthRequiredToStarting: true } : {}), enabled: server.status !== 'disabled' && server.status !== 'not_configured', diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 8507db29f964..580f3a85fdcb 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -216,6 +216,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; handleMcpAuthRequest(request: McpAuthRequest, context: McpAuthContext): Promise; requestUnsandboxedCommandConfirmation(request: IUnsandboxedCommandConfirmationRequest): Promise; handlePreToolUse(input: PreToolUseHookInput): Promise; @@ -706,7 +707,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } }; if (plan.kind === 'create') { - return this._createSession(plan, config, sandboxConfig); + return this._createSession(plan, config, sandboxConfig, runtime); } let fallbackPlan = plan; @@ -714,7 +715,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { const session = AgentSession.uri('copilotcli', plan.sessionId); try { const raw = await this._resumeSession(session, plan, config); - return this._finalizeSession(raw, sandboxConfig, plan, plan.fallback.model?.id, config); + return this._finalizeSession(raw, sandboxConfig, plan, plan.fallback.model?.id, config, runtime); } catch (err) { let resumeError = err; const errCode = getCopilotSdkErrorCode(resumeError); @@ -726,7 +727,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { this._logService.warn(`[Copilot:${plan.sessionId}] Stored custom agent '${plan.resolvedAgentName}' was not found; retrying resume without a custom agent`); try { const raw = await this._resumeSession(session, fallbackPlan, fallbackConfig); - return this._finalizeSession(raw, sandboxConfig, fallbackPlan, fallbackPlan.fallback.model?.id, fallbackConfig); + return this._finalizeSession(raw, sandboxConfig, fallbackPlan, fallbackPlan.fallback.model?.id, fallbackConfig, runtime); } catch (retryErr) { resumeError = retryErr; this._logService.warn(`[Copilot:${plan.sessionId}] SDK resumeSession without custom agent failed: code=${getCopilotSdkErrorCode(retryErr)}, message=${getErrorMessage(retryErr)}`); @@ -747,7 +748,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { model: fallbackPlan.fallback.model, longContextWindow: fallbackPlan.fallback.longContextWindow, freeLongContext: fallbackPlan.fallback.freeLongContext, - }, fallbackConfig, sandboxConfig); + }, fallbackConfig, sandboxConfig, runtime); this._sessionOpenTelemetry.sdkResumeFallbackCreated(session); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); return wrapper; @@ -776,7 +777,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { return this._otelService.withTraceContext(this._otelService.getSessionTraceContext(sessionId, sessionUri), fn); } - private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: ResumeSessionConfig, sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise): Promise { + private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: ResumeSessionConfig, sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise, runtime: ICopilotSessionRuntime): Promise { const raw = await this._withTraceContext(plan.sessionId, () => plan.client.createSession({ ...config, sessionId: plan.sessionId, @@ -787,15 +788,15 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, })); - return this._finalizeSession(raw, sandboxConfig, plan, plan.model?.id, config); + return this._finalizeSession(raw, sandboxConfig, plan, plan.model?.id, config, runtime); } - private async _finalizeSession(raw: CopilotSessionWrapper['session'], sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise, plan: CopilotSessionLaunchPlan, modelId: string | undefined, config: ResumeSessionConfig): Promise { + private async _finalizeSession(raw: CopilotSessionWrapper['session'], sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise, plan: CopilotSessionLaunchPlan, modelId: string | undefined, config: ResumeSessionConfig, runtime: ICopilotSessionRuntime): Promise { plan.stageRecorder?.mark('finalize'); try { await this._applyScriptSafety(raw, plan.sessionId); await sandboxConfig(raw); - await this._reconcileCopilotConnectors(raw, plan); + runtime.setMcpServerDisplayNames(await this._reconcileCopilotConnectors(raw, plan)); } catch (err) { // Nothing owns `raw` until it is wrapped below, so a fail-closed launch has // to disconnect it here or the runtime keeps an orphaned session alive. @@ -809,15 +810,16 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { return new CopilotSessionWrapper(raw, config.requestCanvasRenderer === true, { providers: config.providers, models: config.models }, this._logService); } - private async _reconcileCopilotConnectors(session: CopilotSessionWrapper['session'], plan: CopilotSessionLaunchPlan): Promise { + private async _reconcileCopilotConnectors(session: CopilotSessionWrapper['session'], plan: CopilotSessionLaunchPlan): Promise> { + const displayNames = new Map(); if (this._configurationService.getRootValue(platformRootSchema, AgentHostMcpConnectorsEnabledConfigKey) !== true) { - return; + return displayNames; } try { const capabilities = await session.rpc.connectors.getCapabilities(); if (capabilities.availability !== 'enabled') { this._logService.info(`[Copilot:${plan.sessionId}] Connector MCP reconciliation unavailable: ${capabilities.availability}`); - return; + return displayNames; } const token = plan.githubCredentials.token; const auth = await session.rpc.gitHubAuth.getStatus(); @@ -837,13 +839,18 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } if (!account?.selectionId) { this._logService.warn(`[Copilot:${plan.sessionId}] Connector MCP reconciliation skipped because the session account could not be resolved`); - return; + return displayNames; } const status = await session.rpc.connectors.reconcile({ accountId: account.selectionId, refreshCatalog: true }); + const connectorDisplayNames = new Map(status.catalog?.connectors.map(connector => [connector.name, connector.displayName.trim() || connector.name])); + for (const server of status.runtimeServers) { + displayNames.set(server.runtimeServerId, connectorDisplayNames.get(server.connectorName) ?? server.connectorName); + } this._logService.info(`[Copilot:${plan.sessionId}] Reconciled ${status.runtimeServers.length} connector MCP server(s) through the Copilot runtime`); } catch (error) { this._logService.warn(`[Copilot:${plan.sessionId}] Connector MCP reconciliation failed; continuing without connector tools: ${getErrorMessage(error)}`); } + return displayNames; } /** diff --git a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts index 08fdde060172..ef1c1cc6f364 100644 --- a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts +++ b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import { AgentSession } from '../../common/agent.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; -import { McpServerSource, readMcpServerSource, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; +import { McpServerSource, readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; import { CustomizationLoadStatus, CustomizationType, McpServerStatus, type AhpMcpUiHostCapabilities, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; import { DEFAULT_MCP_APP, DEFAULT_MCP_APP_CAPABILITIES } from '../../common/state/protocol/mcpAppDefaults.js'; import { parseChatUri } from '../../common/state/sessionState.js'; @@ -26,6 +26,8 @@ import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostState export interface ISdkMcpServer { /** Server name (used both as the customization name and the channel suffix). */ readonly name: string; + /** Optional Connector catalog name that does not participate in runtime identity. */ + readonly displayName?: string; readonly source?: McpServerSource; /** Configuration file URI. Omitted on lifecycle updates; null clears a previously known source. */ readonly sourceUri?: string | null; @@ -131,6 +133,7 @@ export interface IMcpCustomizationControllerOptions { interface ILiveEntry { readonly serverName: string; + readonly displayName?: string; readonly state: McpServerState; readonly enabled: boolean; readonly publishedId: string; @@ -223,7 +226,7 @@ export class McpCustomizationController extends Disposable { if (entry.topLevelId === undefined) { continue; } - out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.state, entry.enabled, readMcpServerSource(entry.topLevelCustomization), entry.topLevelCustomization?.uri)); + out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.displayName, entry.state, entry.enabled, readMcpServerSource(entry.topLevelCustomization), entry.topLevelCustomization?.uri)); } return out; } @@ -359,6 +362,7 @@ export class McpCustomizationController extends Disposable { const previous = this._live.get().get(server.name); const state = this._stateForUpdate(previous?.state, server.state, server.allowAuthRequiredToStarting === true); const enabled = server.enabled ?? previous?.enabled ?? true; + const displayName = server.displayName ?? previous?.displayName; // Once promoted to a top-level entry, stay top-level for the // session — flipping back to a child mid-stream would orphan the // previously-published top-level id. @@ -372,8 +376,8 @@ export class McpCustomizationController extends Disposable { const childContainerId = published?.childContainerId ?? (retainChildIdentity ? previous?.childContainerId : undefined); if (childId !== undefined) { const stateChanged = force || previous?.topLevelId !== undefined || previous?.publishedId !== childId || previous?.childContainerId !== childContainerId || !equals(previous?.state, state); - if (stateChanged || previous?.enabled !== enabled) { - this._setLiveEntry(server.name, { serverName: server.name, state, enabled, publishedId: childId, topLevelId: undefined, childContainerId }, tx); + if (stateChanged || previous?.enabled !== enabled || previous?.displayName !== displayName) { + this._setLiveEntry(server.name, { serverName: server.name, displayName, state, enabled, publishedId: childId, topLevelId: undefined, childContainerId }, tx); } if (!stateChanged) { return; @@ -390,10 +394,11 @@ export class McpCustomizationController extends Disposable { } const source = server.source ?? readMcpServerSource(previous?.topLevelCustomization); const sourceUri = server.sourceUri !== undefined ? server.sourceUri : previous?.topLevelCustomization?.uri; - const customization = this._buildTopLevel(topLevelId, server.name, state, enabled, source, sourceUri); + const customization = this._buildTopLevel(topLevelId, server.name, displayName, state, enabled, source, sourceUri); + const resolvedDisplayName = readMcpServerDisplayName(customization); const customizationChanged = force || previous?.topLevelId !== topLevelId || !equals(previous?.topLevelCustomization, customization); if (customizationChanged || previous?.enabled !== enabled) { - this._setLiveEntry(server.name, { serverName: server.name, state, enabled, publishedId: topLevelId, topLevelId, topLevelCustomization: customization }, tx); + this._setLiveEntry(server.name, { serverName: server.name, displayName: resolvedDisplayName, state, enabled, publishedId: topLevelId, topLevelId, topLevelCustomization: customization }, tx); } if (!customizationChanged) { return; @@ -511,7 +516,7 @@ export class McpCustomizationController extends Disposable { return buildMcpChannel(this._chatUri, serverName); } - private _buildTopLevel(id: string, serverName: string, state: McpServerState, enabled: boolean, source?: McpServerSource, sourceUri?: string | null): McpServerCustomization { + private _buildTopLevel(id: string, serverName: string, displayName: string | undefined, state: McpServerState, enabled: boolean, source?: McpServerSource, sourceUri?: string | null): McpServerCustomization { const channel = this._buildChannel(serverName, state); const owningPluginUri = this.pluginMcpServerSources?.get(serverName); // Per AHP spec, `mcpApp` is a static capability declaration — @@ -525,7 +530,7 @@ export class McpCustomizationController extends Disposable { const existing = getMcpServerCustomizations(this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? []) .find(customization => customization.id === id); // `SessionCustomizationUpdated` replaces the whole customization, so keep opaque entries owned by others. - const meta = withMcpServerSourceMeta(existing?._meta, source); + const meta = withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, source), displayName); const uri = (sourceUri === undefined ? existing?.uri : sourceUri) ?? this._mintTopLevelId(serverName); const customization: McpServerCustomization = { type: CustomizationType.McpServer, diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index e69da2ead225..ea23f0b22064 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -12,7 +12,7 @@ import { readSessionSandboxPolicy, withSessionSandboxPolicy } from '../../common import { readChatInputState, withChatInputState } from '../../common/meta/agentHostChatInputState.js'; import { createEditorInlineChatInstruction, createTerminalChatInstruction, readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { readAgentCustomizationMeta, toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; -import { readMcpServerSource, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; import { getCommandArgumentHint, getCompletionAction, readCompletionAttachmentMeta, toCommandCompletionAttachmentMeta, toSkillCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; import { CustomizationType, MessageAttachmentKind, ToolCallStatus, hasReportedUsage, readSessionComparisonMetadata, readUsageInfoMeta, withSessionComparisonMetadata, type AgentCustomization, type ClientPluginCustomization, type ToolCallState, type UsageInfo } from '../../common/state/sessionState.js'; import { McpServerStatus, type McpServerCustomization, type SessionModelInfo, type SimpleMessageAttachment } from '../../common/state/protocol/state.js'; @@ -122,8 +122,8 @@ suite('Agent host _meta readers', () => { })?.role, 'judge'); }); - test('validates MCP configuration sources and merges them into open metadata', () => { - const read = (meta: Record | undefined) => readMcpServerSource({ + test('validates MCP presentation metadata and preserves opaque entries', () => { + const server = (meta: Record | undefined) => ({ type: CustomizationType.McpServer, id: 'server', uri: 'mcp-top-level:server', @@ -132,18 +132,21 @@ suite('Agent host _meta readers', () => { _meta: meta, } satisfies McpServerCustomization); const opaque = { 'test.opaque': 'kept' }; + const merged = withMcpServerDisplayNameMeta(withMcpServerSourceMeta(opaque, 'user'), ' Mail '); assert.deepStrictEqual({ sources: [ - ...(['user', 'workspace', 'plugin', 'builtin', 'managed'] as const).map(source => read(withMcpServerSourceMeta(undefined, source))), - ...[undefined, 'unknown', 1, {}, ['user']].map(source => read({ 'agentHost.mcpServerSource': source })), - read(undefined), + ...(['user', 'workspace', 'plugin', 'builtin', 'managed'] as const).map(source => readMcpServerSource(server(withMcpServerSourceMeta(undefined, source)))), + ...[undefined, 'unknown', 1, {}, ['user']].map(source => readMcpServerSource(server({ 'agentHost.mcpServerSource': source }))), + readMcpServerSource(server(undefined)), ], - replaced: withMcpServerSourceMeta(withMcpServerSourceMeta(opaque, 'user'), 'workspace'), + displayNames: [' Mail ', '', 'x'.repeat(513), 1].map(displayName => readMcpServerDisplayName(server({ 'vscode.mcpServerDisplayName': displayName }))), + merged, unchanged: withMcpServerSourceMeta(opaque, undefined) === opaque, }, { sources: ['user', 'workspace', 'plugin', 'builtin', 'managed', undefined, undefined, undefined, undefined, undefined, undefined], - replaced: { 'test.opaque': 'kept', 'agentHost.mcpServerSource': 'workspace' }, + displayNames: ['Mail', undefined, undefined, undefined], + merged: { 'test.opaque': 'kept', 'agentHost.mcpServerSource': 'user', 'vscode.mcpServerDisplayName': 'Mail' }, unchanged: true, }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 21c650f0117b..6c24600972b7 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -39,7 +39,7 @@ import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanR import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { ChatInputRequestPurpose, readChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; -import { readMcpServerSource } from '../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource } from '../../common/meta/mcpCustomizationMeta.js'; import { agentModelCallMetaKey, readAgentModelCallDiagnostics } from '../../common/meta/agentModelCallMeta.js'; import { readAgentRuntimeModelConfiguration } from '../../common/meta/agentModelConfigurationMeta.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, readAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; @@ -20317,6 +20317,36 @@ Use the attached image as context. assert.strictEqual(session.requiresMcpLaunchConfigurationRefresh, true); }); + test('republishes pending connector authentication when display names become available', async () => { + const runtimeServerId = 'github-copilot-connector-94d26095770df60673dd'; + const resource = 'https://api.github.com'; + const { session, runtime, waitForSignal } = await createAgentSession(disposables); + + const authPromise = runtime.handleMcpAuthRequest({ + requestId: 'auth-connector', + serverName: runtimeServerId, + serverUrl: resource, + reason: 'upscope', + }, { sessionId: 'test-session-1' }); + await waitForSignal(signal => isAction(signal, ActionType.SessionCustomizationUpdated)); + const beforeDisplayName = readMcpServerDisplayName(session.topLevelMcpCustomizations()[0]); + runtime.setMcpServerDisplayNames(new Map([[runtimeServerId, 'GitHub']])); + const [server] = session.topLevelMcpCustomizations(); + await session.resolveMcpAuthentication({ resource, scopes: [], token: 'connector-token' }); + + assert.deepStrictEqual({ + beforeDisplayName, + name: server.name, + displayName: readMcpServerDisplayName(server), + result: await authPromise, + }, { + beforeDisplayName: undefined, + name: runtimeServerId, + displayName: 'GitHub', + result: { kind: 'token', accessToken: 'connector-token' }, + }); + }); + test('MCP authentication only clears auth-required after all matching server challenges resolve', async () => { const { session, runtime, signals, mockSession } = await createAgentSession(disposables); const firstAuth = runtime.handleMcpAuthRequest({ diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 5c70f26075b0..87dc9106c2a3 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -52,6 +52,7 @@ const testRuntime: ICopilotSessionRuntime = { handleExitPlanModeRequest: async () => { throw new Error('Unexpected exit plan mode request'); }, handleUserInputRequest: async () => { throw new Error('Unexpected user input request'); }, handleElicitationRequest: async () => { throw new Error('Unexpected elicitation request'); }, + setMcpServerDisplayNames: () => { }, handleMcpAuthRequest: async () => { throw new Error('Unexpected MCP auth request'); }, requestUnsandboxedCommandConfirmation: async () => false, handlePreToolUse: async () => { }, @@ -686,6 +687,7 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { test('reconciles connector MCP servers through the Copilot runtime before launch completes', async () => { const connectorCalls: string[] = []; + let connectorDisplayNames: ReadonlyMap = new Map(); let featureFlags: Record | undefined; const session = { sessionId: 'connector-session', @@ -709,6 +711,11 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { return { apiVersion: 1, availability: 'enabled' as const, + catalog: { + revision: 1, + refreshedAtMs: 1, + connectors: [{ name: 'mail', displayName: 'Work IQ Mail', status: 'connected' as const, runtimeServerIds: ['connector-mail'] }], + }, runtimeServers: [{ runtimeServerId: 'connector-mail', connectorName: 'mail', status: 'connected' as const }], pendingConnections: 0, }; @@ -755,14 +762,19 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { model: undefined, }; - const launched = await launcher.launch(plan, testRuntime); + const launched = await launcher.launch(plan, { + ...testRuntime, + setMcpServerDisplayNames: displayNames => connectorDisplayNames = new Map(displayNames), + }); try { assert.deepStrictEqual({ featureFlags, connectorCalls, + connectorDisplayName: connectorDisplayNames.get('connector-mail'), }, { featureFlags: { CONNECTORS: true, TGREP: false, CONTENT_EXCLUSION: true, MANAGED_MCP_SERVERS: true }, connectorCalls: ['capabilities', 'auth', 'accounts', 'reconcile:account-1:true'], + connectorDisplayName: 'Work IQ Mail', }); } finally { launched.dispose(); diff --git a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts index 026a5538d3f2..f6647e63ddbe 100644 --- a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { NullLogService } from '../../../../../platform/log/common/log.js'; import { AgentSession } from '../../../common/agent.js'; import { isCustomizationEnabled } from '../../../common/customizationEnablement.js'; -import { readMcpServerSource, withMcpServerSourceMeta } from '../../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource, withMcpServerSourceMeta } from '../../../common/meta/mcpCustomizationMeta.js'; import { ActionType } from '../../../common/state/protocol/common/actions.js'; import { CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpAuthRequiredReason, McpServerStatus, SessionStatus, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState, type PluginCustomization } from '../../../common/state/protocol/channels-session/state.js'; import { buildChatUri } from '../../../common/state/sessionState.js'; @@ -158,15 +158,15 @@ suite('McpCustomizationController', () => { assert.deepStrictEqual(controller.topLevelCustomizations(), []); }); - test('retains source through lifecycle updates and republishes source-only inventory changes', () => { + test('retains presentation metadata through lifecycle updates and republishes inventory changes', () => { const { controller, actions } = harness(store); store.add(controller); const snapshot = () => controller.topLevelCustomizations().map(item => ({ - id: item.id, source: readMcpServerSource(item), state: item.state.kind, + id: item.id, displayName: readMcpServerDisplayName(item), source: readMcpServerSource(item), state: item.state.kind, })); controller.applyOne(server('search', starting())); - controller.applyAll([{ ...server('search', starting()), source: 'user' }]); + controller.applyAll([{ ...server('search', starting()), displayName: 'Connector Search', source: 'user' }]); const afterInventory = snapshot(); controller.applyOne(server('search', ready())); const afterLifecycle = snapshot(); @@ -177,11 +177,13 @@ suite('McpCustomizationController', () => { afterLifecycle, afterSourceChange: snapshot(), publishedSources: actions.flatMap(action => action.type === ActionType.SessionCustomizationUpdated && action.customization.type === CustomizationType.McpServer ? [readMcpServerSource(action.customization)] : []), + publishedDisplayNames: actions.flatMap(action => action.type === ActionType.SessionCustomizationUpdated && action.customization.type === CustomizationType.McpServer ? [readMcpServerDisplayName(action.customization)] : []), }, { - afterInventory: [{ id: 'mcp-top-level:copilot:session-1:search', source: 'user', state: McpServerStatus.Starting }], - afterLifecycle: [{ id: 'mcp-top-level:copilot:session-1:search', source: 'user', state: McpServerStatus.Ready }], - afterSourceChange: [{ id: 'mcp-top-level:copilot:session-1:search', source: 'workspace', state: McpServerStatus.Ready }], + afterInventory: [{ id: 'mcp-top-level:copilot:session-1:search', displayName: 'Connector Search', source: 'user', state: McpServerStatus.Starting }], + afterLifecycle: [{ id: 'mcp-top-level:copilot:session-1:search', displayName: 'Connector Search', source: 'user', state: McpServerStatus.Ready }], + afterSourceChange: [{ id: 'mcp-top-level:copilot:session-1:search', displayName: 'Connector Search', source: 'workspace', state: McpServerStatus.Ready }], publishedSources: [undefined, 'user', 'user', 'workspace'], + publishedDisplayNames: [undefined, 'Connector Search', 'Connector Search', 'Connector Search'], }); }); diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 75f014d00e2f..52517c83f7ec 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -98,6 +98,8 @@ export interface IAgentHostGroup { export interface IAgentHostMcpServer { readonly id: string; readonly name: string; + /** Optional Connector catalog name for presentation only. */ + readonly displayName?: string; readonly source?: McpServerSource; readonly enabled: boolean; readonly enablement?: readonly CustomizationEnablement[]; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index 9628bf6b0490..c413c19d10fd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -16,7 +16,7 @@ import { IAgentConnection } from '../../../../../../platform/agentHost/common/ag import { IAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { getEffectiveAgents } from '../../../../../../platform/agentHost/common/customAgents.js'; -import { readMcpServerSource } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; @@ -39,6 +39,12 @@ import { ContributionEnablementState } from '../../../common/enablement.js'; export const IAgentHostCustomizationService = createDecorator('agentHostCustomizationService'); +export function getMcpServerDisplayLabel(server: Pick): string { + return server.displayName + ? localize('agentHost.mcpServer.connectorDisplayName', "{0} (Connector)", server.displayName) + : server.name; +} + export interface IAgentHostCustomizationService { readonly _serviceBrand: undefined; readonly onDidChangeCustomAgents: Event; @@ -250,6 +256,7 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i return { id: this._scopedMcpServerId(sessionResource, server.id), name: server.name, + displayName: readMcpServerDisplayName(server), source: readMcpServerSource(server), enabled: isCustomizationEnabled(server) && (!plugin || isCustomizationEnabled(plugin)), enablement: server.enablement, @@ -341,13 +348,14 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i if (!server || server.state.kind !== McpServerStatus.AuthRequired) { return false; } + const displayName = readMcpServerDisplayName(server); try { await target.startMcpServer(server.id); return await this._instantiationService.invokeFunction(resolveMcpServerAuthentication, server.state.resource, { allowInteraction: true, logPrefix: '[AgentHost]', mcpServerId: agentHostMcpServerId(sessionResource.authority, server.name, server.state.resource.resource), - mcpServerName: server.name, + mcpServerName: getMcpServerDisplayLabel({ name: server.name, displayName }), mcpServerUrl: server.state.resource.resource, oauthClient: server.state.oauthClient, scopes: server.state.requiredScopes ?? [], diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index ba0aed8535e0..24f814827a96 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -111,7 +111,7 @@ import { ILanguageModelToolsService, IToolData, IToolResult, stringifyPromptTsxP import { IChatWidgetService } from '../../chat.js'; import { getAgentSessionProviderIcon } from '../agentSessions.js'; import { IAgentCustomizationScope, IAgentHostActiveClientService } from './agentHostActiveClientService.js'; -import { IAgentHostCustomizationService } from './agentHostCustomizationService.js'; +import { getMcpServerDisplayLabel, IAgentHostCustomizationService } from './agentHostCustomizationService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostSessionWorkingDirectorySynchronizer } from './agentHostSessionWorkingDirectorySynchronizer.js'; import { IAgentHostShellInitSynchronizer } from './agentHostShellInitSynchronizer.js'; @@ -3582,9 +3582,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }); const mcpStarting$ = derivedOpts({ equalsFn: equals }, reader => { const state = mergedState$.read(reader); - const backgroundActions = new Map(this._customizationService.getMcpServers(opts.sessionResource) + const sessionServers = this._customizationService.getMcpServers(opts.sessionResource); + const backgroundActions = new Map(sessionServers .filter(server => server.background !== undefined) .map(server => [server.id, server.background])); + const displayLabels = new Map(sessionServers.map(server => [server.id, getMcpServerDisplayLabel(server)])); const servers = state?.customizations?.flatMap(c => c.type === CustomizationType.McpServer ? [c] : c.children?.filter(c => c.type === CustomizationType.McpServer) ?? []) ?? []; @@ -3594,7 +3596,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const id = opts.sessionResource.authority + '/' + server.id; return { id, - name: server.name, + name: displayLabels.get(id) ?? server.name, blocking: server.state.kind === McpServerStatus.Starting && server.state.blocking === true, background: backgroundActions.get(id), }; @@ -4302,7 +4304,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC opts.sink([invocation]); } } else { - invocation = toolCallStateToInvocation(initial, subAgentInvocationId, opts.backendSession, this._config.connectionAuthority, opts.sessionResource.authority, undefined, this._config.connection.resourceUris); + const mcpServerName = initial.status === ToolCallStatus.AuthRequired ? this._toolAuthenticationServerName(initial, opts) : undefined; + invocation = toolCallStateToInvocation(initial, subAgentInvocationId, opts.backendSession, this._config.connectionAuthority, opts.sessionResource.authority, undefined, this._config.connection.resourceUris, mcpServerName); if (!renderedBySnapshot) { opts.sink([invocation]); } @@ -4363,7 +4366,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC invocation.updatePreparedInvocation(prepared, invocation.parameters); } else if (status === ToolCallStatus.AuthRequired) { this._ensureLeftStreaming(invocation, tc, opts); - invocation.setAuthenticationRequired(toolCallAuthenticationServer(tc, opts.sessionResource.authority), () => { + invocation.setAuthenticationRequired(toolCallAuthenticationServer(tc, opts.sessionResource.authority, this._toolAuthenticationServerName(tc, opts)), () => { this._dispatchAction(opts.backendSession, { type: ActionType.ChatToolCallComplete, turnId: opts.turnId, @@ -4413,6 +4416,29 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC invocation.didExecuteTool(undefined); } })); + this._refreshToolAuthenticationServerName(invocation, opts.sessionResource); + store.add(this._customizationService.onDidChangeCustomizations(() => this._refreshToolAuthenticationServerName(invocation, opts.sessionResource))); + } + + private _refreshToolAuthenticationServerName(invocation: ChatToolInvocation, sessionResource: URI): void { + const state = invocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForAuthentication) { + return; + } + const server = this._customizationService.getMcpServers(sessionResource).find(candidate => candidate.id === state.server.id); + if (!server) { + return; + } + const name = getMcpServerDisplayLabel(server); + if (name !== state.server.name) { + invocation.setAuthenticationRequired({ ...state.server, name }); + } + } + + private _toolAuthenticationServerName(tc: ToolCallState & { status: ToolCallStatus.AuthRequired }, opts: IObserveTurnOptions): string | undefined { + const id = toolCallAuthenticationServer(tc, opts.sessionResource.authority).id; + const server = this._customizationService.getMcpServers(opts.sessionResource).find(candidate => candidate.id === id); + return server ? getMcpServerDisplayLabel(server) : undefined; } /** Transitions an invocation from streaming once its AHP tool call is ready. */ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index cf28a2c33028..aeb3b28d9fdd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -2530,7 +2530,7 @@ function addCommentReference(tc: ToolCallState, resourceUris: IAgentHostResource * wrapping remote file URIs into `vscode-agent-host:` URIs. Omit to skip * URI wrapping (e.g. in tests that don't exercise the confirmation UI). */ -export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string, mcpServerAuthority = sessionResource.authority, options?: IAgentHostToolInvocationOptions, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority)): ChatToolInvocation { +export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string, mcpServerAuthority = sessionResource.authority, options?: IAgentHostToolInvocationOptions, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority), mcpServerName?: string): ChatToolInvocation { const toolData: IToolData = { id: tc.toolName, source: ToolDataSource.Internal, @@ -2633,7 +2633,7 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI invocation.presentation = ToolInvocationPresentation.Hidden; } if (tc.status === ToolCallStatus.AuthRequired) { - invocation.setAuthenticationRequired(toolCallAuthenticationServer(tc, mcpServerAuthority)); + invocation.setAuthenticationRequired(toolCallAuthenticationServer(tc, mcpServerAuthority, mcpServerName)); } applyToolCallProgress(invocation, tc); @@ -2695,11 +2695,11 @@ export function toolCallConfirmationMessages(tc: ToolCallPendingConfirmationStat }; } -export function toolCallAuthenticationServer(tc: ToolCallState & { status: ToolCallStatus.AuthRequired }, sessionAuthority: string): IChatMcpAuthenticationRequiredServer { +export function toolCallAuthenticationServer(tc: ToolCallState & { status: ToolCallStatus.AuthRequired }, sessionAuthority: string, name?: string): IChatMcpAuthenticationRequiredServer { const metadata = readToolCallMeta(tc); return { id: `${sessionAuthority}/${tc.contributor.customizationId}`, - name: tc.auth.resource.resource_name ?? metadata.mcpServerName ?? tc.displayName, + name: name ?? tc.auth.resource.resource_name ?? metadata.mcpServerName ?? tc.displayName, resource: tc.auth.resource.resource, oauthClient: tc.auth.oauthClient, authorizationServers: tc.auth.resource.authorization_servers, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 7ee6cfba7c2b..726cf158ae59 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -57,7 +57,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; import { CustomizationMcpServerCompatibilityKind, getCustomizationDisabledLabel, ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; -import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; +import { getMcpServerDisplayLabel, IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; import { CustomizationEnablementKind, McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { IOutputService } from '../../../../services/output/common/output.js'; import { ChatConfiguration } from '../../common/constants.js'; @@ -470,7 +470,7 @@ export class McpServerItemRenderer extends Disposable implements IListRenderer value.toLowerCase().includes(query))) - .map(server => createConnectorMcpEntry(server, activeSessionMatcher.take([server.serverName]))) + .map(server => createConnectorMcpEntry(server, activeSessionMatcher.takeConnector([server.serverName], [server.connector.name, server.connector.displayName]))) : []; const activeSessionOnlyServers = activeSessionMatcher.unmatched(query); const activeSessionBuiltinEntries = createBuiltinActiveSessionMcpEntries(activeSessionOnlyServers); @@ -3199,7 +3199,7 @@ export class McpListWidget extends Disposable { case 'server-item': return entry.server.label === serverName; case 'session-server-item': - return entry.server.name === serverName; + return entry.server.name === serverName || entry.server.displayName === serverName; case 'builtin-item': return entry.label === serverName || entry.connector?.connector.displayName === serverName; case 'group-header': diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts index a67530b4bf1e..6280b7e9e0d6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts @@ -51,8 +51,22 @@ export class ActiveSessionMcpServerMatcher { return undefined; } + takeConnector(keys: readonly (string | undefined)[], displayNames: readonly (string | undefined)[]): AgentHostMcpServer | undefined { + const exactMatch = this.take(keys); + if (exactMatch) { + return exactMatch; + } + const names = new Set(getUniqueMcpMatchKeys(displayNames)); + const matches = this.servers.filter(server => !this.matchedIds.has(server.id) && server.displayName !== undefined && names.has(server.displayName)); + if (matches.length === 1) { + this.matchedIds.add(matches[0].id); + return matches[0]; + } + return undefined; + } + unmatched(query: string): AgentHostMcpServer[] { - return this.servers.filter(server => !this.matchedIds.has(server.id) && (!query || server.name.toLowerCase().includes(query))); + return this.servers.filter(server => !this.matchedIds.has(server.id) && (!query || [server.name, server.displayName].some(value => value?.toLowerCase().includes(query)))); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.ts index c93786cc8bed..4ea143568850 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.ts @@ -6,7 +6,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { escapeMarkdownSyntaxTokens, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { appendEscapedMarkdownInlineCode, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, observableValue } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; @@ -14,7 +14,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { McpServerStatus } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { IAgentHostCustomizationService } from '../../agentSessions/agentHost/agentHostCustomizationService.js'; +import { getMcpServerDisplayLabel, IAgentHostCustomizationService } from '../../agentSessions/agentHost/agentHostCustomizationService.js'; import { IChatMcpAuthenticationRequired, IChatMcpAuthenticationRequiredServer } from '../../../common/chatService/chatService.js'; import { ChatTreeItem } from '../../chat.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; @@ -86,7 +86,7 @@ export class ChatMcpAuthenticationContentPart extends Disposable implements ICha const dataServerIds = new Set(dataServers.map(server => server.id)); return this.agentHostCustomizationService.getMcpServers(sessionResource) .filter(server => dataServerIds.has(server.id) && server.enabled && server.status === McpServerStatus.AuthRequired) - .map(server => ({ id: server.id, name: server.name })); + .map(server => ({ id: server.id, name: getMcpServerDisplayLabel(server) })); } private render(servers: readonly Pick[], authenticating: IChatMcpAuthenticationRequiredServer | undefined): void { @@ -98,7 +98,7 @@ export class ChatMcpAuthenticationContentPart extends Disposable implements ICha if (authenticating) { this._renderMessage( ThemeIcon.modify(Codicon.loading, 'spin'), - localize('mcp.auth.authenticating', 'Authenticating {0}...', '`' + escapeMarkdownSyntaxTokens(authenticating.name) + '`'), + localize('mcp.auth.authenticating', 'Authenticating {0}...', appendEscapedMarkdownInlineCode(authenticating.name)), ); if (actionHadFocus) { this.options.onDidRemoveFocusedAction?.(); @@ -114,7 +114,7 @@ export class ChatMcpAuthenticationContentPart extends Disposable implements ICha } const links = servers - .map(server => '`' + escapeMarkdownSyntaxTokens(server.name) + '`') + .map(server => appendEscapedMarkdownInlineCode(server.name)) .join(', '); const content = servers.length === 1 ? localize('mcp.auth.single', 'The MCP server {0} requires authentication. [Authenticate](#authenticate)?', links) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts index 5ccd67323a5c..48133f1668b7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts @@ -7,7 +7,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; import { createPixelSpinner, IPixelSpinner } from '../../../../../../base/browser/ui/pixelSpinner/pixelSpinner.js'; import { onUnexpectedError } from '../../../../../../base/common/errors.js'; -import { escapeMarkdownSyntaxTokens, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { appendEscapedMarkdownInlineCode, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { localize } from '../../../../../../nls.js'; @@ -81,7 +81,7 @@ export class ChatMcpServersStartingContentPart extends Disposable implements ICh const backgroundableServers = blockingServers.filter(server => server.background !== undefined); const visibleServers = blockingServers.length ? blockingServers : servers; const links = visibleServers - .map(server => '`' + escapeMarkdownSyntaxTokens(server.name) + '`') + .map(server => appendEscapedMarkdownInlineCode(server.name)) .join(', '); this.skipAction = this._renderMessage( blockingServers.length diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolAuthenticationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolAuthenticationSubPart.ts index d39962773368..6faa916c28bf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolAuthenticationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolAuthenticationSubPart.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../../../nls.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; -import { IAgentHostCustomizationService } from '../../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { getMcpServerDisplayLabel, IAgentHostCustomizationService } from '../../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; import { IChatToolInvocation } from '../../../../common/chatService/chatService.js'; import { IChatWidgetService } from '../../../chat.js'; import { ChatCustomConfirmationWidget } from '../chatConfirmationWidget.js'; @@ -30,6 +31,7 @@ export class ChatToolAuthenticationSubPart extends BaseChatToolInvocationSubPart throw new Error('Tool authentication state is missing'); } const mcpServer = customizationService.getMcpServers(context.element.sessionResource).find(server => server.id === state.server.id); + const serverName = mcpServer ? getMcpServerDisplayLabel(mcpServer) : state.server.name; const widget = this._register(instantiationService.createInstance( ChatCustomConfirmationWidget<() => Promise>, @@ -37,7 +39,7 @@ export class ChatToolAuthenticationSubPart extends BaseChatToolInvocationSubPart { title: localize('chat.toolAuthentication.title', "MCP authentication required"), icon: Codicon.mcp, - subtitle: state.server.name, + subtitle: serverName, buttons: [ { label: localize('chat.toolAuthentication.authenticate', "Authenticate"), @@ -61,7 +63,7 @@ export class ChatToolAuthenticationSubPart extends BaseChatToolInvocationSubPart isSecondary: true, }] : []), ], - message: localize('chat.toolAuthentication.message', "The MCP server {0} requires authentication to continue this tool call.", state.server.name), + message: new MarkdownString().appendText(localize('chat.toolAuthentication.message', "The MCP server {0} requires authentication to continue this tool call.", serverName)), toolbarData: { arg: toolInvocation, partType: 'chatToolAuthentication', diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts index d9b2583a8c61..5d04782a566a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts @@ -15,7 +15,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { withMcpServerSourceMeta } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import { withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType, type ActionEnvelope } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerCustomization, McpServerStatus, type Customization, type CustomizationEnablement } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -138,7 +138,7 @@ class TestSessionSubscription extends mock>() { suite('AbstractAgentHostCustomizationService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createSut(authenticationError?: Error): TestAgentHostCustomizationService { + function createSut(authenticationError?: Error, authenticationTargets?: Array<{ id: string; name: string }>): TestAgentHostCustomizationService { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ILoggerService, store.add(new NullLoggerService())); instantiationService.stub(ILogService, new NullLogService()); @@ -154,7 +154,9 @@ suite('AbstractAgentHostCustomizationService', () => { }); instantiationService.stub(IAuthenticationMcpAccessService, { isAccessAllowedForUrl: () => true }); instantiationService.stub(IAuthenticationMcpService, { getAccountPreference: () => undefined }); - instantiationService.stub(IAuthenticationMcpUsageService, { addAccountUsage: () => { } }); + instantiationService.stub(IAuthenticationMcpUsageService, { + addAccountUsage: (_providerId, _accountName, _scopes, id, name) => authenticationTargets?.push({ id, name }), + }); instantiationService.stub(IDynamicAuthenticationProviderStorageService, {}); instantiationService.stub(IOutputService, { getChannel: () => undefined, @@ -293,19 +295,19 @@ suite('AbstractAgentHostCustomizationService', () => { ]); }); - test('preserves host-only MCP configuration sources without requiring a source file', () => { + test('preserves host-only MCP presentation metadata without requiring a source file', () => { const sut = createSut(); const session = URI.parse('vscode-agent-session:///session-1'); const sources = ['user', 'workspace', 'plugin', 'builtin', 'managed'] as const; sut.setTarget(session, new FakeTarget(sources.map(source => ({ ...mcpServer(source, source), uri: `mcp-top-level:copilot:session-1:${source}`, - _meta: withMcpServerSourceMeta(undefined, source), + _meta: withMcpServerDisplayNameMeta(withMcpServerSourceMeta(undefined, source), `${source} display`), })))); assert.deepStrictEqual(sut.getMcpServers(session).map(server => ({ - source: server.source, sourceUri: server.sourceUri, - })), sources.map(source => ({ source, sourceUri: undefined }))); + displayName: server.displayName, source: server.source, sourceUri: server.sourceUri, + })), sources.map(source => ({ displayName: `${source} display`, source, sourceUri: undefined }))); }); test('preserves global and session decisions when re-enabling workspace enablement', () => { @@ -369,10 +371,12 @@ suite('AbstractAgentHostCustomizationService', () => { }); test('starts an unchanged auth-required server before forwarding root authentication results', async () => { - const sut = createSut(); + const authenticationTargets: Array<{ id: string; name: string }> = []; + const sut = createSut(undefined, authenticationTargets); const session = URI.parse('vscode-agent-session:///session-1'); const target = new FakeTarget([{ ...mcpServer('server-1', 'Server One'), + _meta: withMcpServerDisplayNameMeta(undefined, 'Connector One'), state: { kind: McpServerStatus.AuthRequired, reason: McpAuthRequiredReason.Required, @@ -390,6 +394,7 @@ suite('AbstractAgentHostCustomizationService', () => { startCalls: target.startCalls, operationLog: target.operationLog, authenticateCalls: target.authenticateCalls, + authenticationTargets, }, { authentication: [true, true], startCalls: ['server-1', 'server-1'], @@ -398,6 +403,10 @@ suite('AbstractAgentHostCustomizationService', () => { { resource: 'https://mcp.example.com', scopes: [], token: 'token' }, { resource: 'https://mcp.example.com', scopes: [], token: 'token' }, ], + authenticationTargets: [ + { id: 'agent-host-mcp:/Server%20One/https%3A%2F%2Fmcp.example.com', name: 'Connector One (Connector)' }, + { id: 'agent-host-mcp:/Server%20One/https%3A%2F%2Fmcp.example.com', name: 'Connector One (Connector)' }, + ], }); target.startError = new Error('start cancelled'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 3b45a7ce87c0..0425384c960a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -1912,7 +1912,7 @@ suite('stateToProgressAdapter', () => { }, requiredScopes: ['repo'], }, - }, undefined, URI.parse('agent-host-copilot://backend/session'), 'remote', 'frontend'); + }, undefined, URI.parse('agent-host-copilot://backend/session'), 'remote', 'frontend', undefined, undefined, 'Example MCP (Connector)'); const state = invocation.state.get(); assert.strictEqual(state.type, IChatToolInvocation.StateKind.WaitingForAuthentication); @@ -1928,7 +1928,7 @@ suite('stateToProgressAdapter', () => { confirmationMessages: undefined, server: { id: 'frontend/mcp-1', - name: 'Example MCP', + name: 'Example MCP (Connector)', resource: 'https://mcp.example.com', oauthClient: { clientId: 'configured-client-id', diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts index 451b7d8a0a05..47b49bcbadb4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts @@ -691,6 +691,25 @@ suite('mcpListWidget', () => { }, { matched: servers.slice(0, 2).map(server => server.id), rows: 13, enabled: 11 }); }); + test('restricts display-name matching to connector rows', () => { + const connectorServer = createAgentHostServer({ + id: 'session/github-copilot-connector-94d26095770df60673dd', + name: 'github-copilot-connector-94d26095770df60673dd', + displayName: 'GitHub', + }); + const matcher = new ActiveSessionMcpServerMatcher([connectorServer]); + + assert.deepStrictEqual({ + genericMatch: matcher.take(['GitHub'])?.id, + connectorMatch: matcher.takeConnector(['github-mcp'], ['github', 'GitHub'])?.id, + unmatched: matcher.unmatched('').map(server => server.id), + }, { + genericMatch: undefined, + connectorMatch: connectorServer.id, + unmatched: [], + }); + }); + test('classifies active-session-only MCP servers as built-in entries', () => { const server = createAgentHostServer({ name: 'node_repl' }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.test.ts index 4d5fb03470fb..56aa2591e383 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.test.ts @@ -32,10 +32,11 @@ suite('ChatMcpAuthenticationContentPart', () => { instantiationService = workbenchInstantiationService(undefined, disposables); }); - function server(id: string, name: string, status: McpServerStatus, enabled = true): McpServer { + function server(id: string, name: string, status: McpServerStatus, enabled = true, displayName?: string): McpServer { return new class extends mock() { override readonly id = id; override readonly name = name; + override readonly displayName = displayName; override readonly status = status; override readonly enabled = enabled; }(); @@ -131,6 +132,26 @@ suite('ChatMcpAuthenticationContentPart', () => { }); }); + test('uses the presentation name without changing the authentication target', async () => { + const runtimeServerId = 'github-copilot-connector-94d26095770df60673dd'; + const { part, authenticated } = createPart( + [{ id: runtimeServerId, name: runtimeServerId, resource: 'https://api.github.com' }], + [server(runtimeServerId, runtimeServerId, McpServerStatus.AuthRequired, true, 'GitHub')], + ); + + const text = part.domNode.textContent; + part.domNode.querySelector('[role="button"]')?.click(); + await timeout(0); + + assert.deepStrictEqual({ + text, + authenticated, + }, { + text: 'The MCP server GitHub (Connector) requires authentication. Authenticate?', + authenticated: [runtimeServerId], + }); + }); + test('remains unused initially and completes once when servers are populated then resolved', () => { let completionCount = 0; const { part, data, servers, setLiveServers } = createPart([], [], undefined, () => completionCount++); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts index 41036727a93c..5500c9d57107 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts @@ -154,6 +154,12 @@ suite('ChatMcpServersStartingContentPart', () => { disposedSpinners: 0, finishedCount: 1, }); + + test('renders connector labels without visible markdown escapes', () => { + const { part } = createPart([{ id: 'connector', name: 'GitHub (Connector)' }]); + + assert.strictEqual(part.domNode.textContent, 'Starting MCP servers GitHub (Connector)...'); + }); }); test('reflects a blocking server becoming nonblocking', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolAuthenticationSubPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolAuthenticationSubPart.test.ts index a33866b80e57..d4de68544d18 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolAuthenticationSubPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolAuthenticationSubPart.test.ts @@ -22,12 +22,14 @@ import { ToolDataSource } from '../../../../common/tools/languageModelToolsServi suite('ChatToolAuthenticationSubPart', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - test('can disable the MCP server for the session', async () => { + test('uses the presentation name while actions target the MCP server id', async () => { const sessionResource = URI.parse('chat-session://local/session'); const enabledChanges: boolean[] = []; + const authenticationTargets: string[] = []; const server = upcastPartial({ - id: 'local/docs', - name: 'Documentation', + id: 'local/github-copilot-connector-94d26095770df60673dd', + name: 'github-copilot-connector-94d26095770df60673dd', + displayName: 'GitHub [Enterprise]', enabled: true, status: McpServerStatus.AuthRequired, state: { @@ -45,6 +47,11 @@ suite('ChatToolAuthenticationSubPart', () => { assert.strictEqual(resource, sessionResource); return [server]; } + override authenticateMcpServer(resource: URI, serverId: string): Promise { + assert.strictEqual(resource, sessionResource); + authenticationTargets.push(serverId); + return Promise.resolve(true); + } } let focused = false; const instantiationService = workbenchInstantiationService(undefined, store); @@ -72,15 +79,31 @@ suite('ChatToolAuthenticationSubPart', () => { const part = store.add(instantiationService.createInstance(ChatToolAuthenticationSubPart, invocation, context)); const buttons = [...part.domNode.querySelectorAll('.monaco-button')]; - assert.deepStrictEqual(buttons.map(button => button.textContent), ['Authenticate', 'Cancel', 'Disable for This Session']); + const presentation = { + title: part.domNode.querySelector('.chat-query-title-part')?.textContent?.trim().replace(/\u00a0/g, ' '), + message: part.domNode.querySelector('.chat-confirmation-widget-message')?.textContent?.replace(/\u00a0/g, ' '), + messageLinks: part.domNode.querySelectorAll('.chat-confirmation-widget-message a').length, + buttons: buttons.map(button => button.textContent), + }; + buttons[0].click(); + await timeout(0); buttons[2].click(); await timeout(0); assert.deepStrictEqual({ + presentation, + authenticationTargets, enabledChanges, focused, cancelled, }, { + presentation: { + title: 'MCP authentication requiredGitHub [Enterprise] (Connector)', + message: 'The MCP server GitHub [Enterprise] (Connector) requires authentication to continue this tool call.', + messageLinks: 0, + buttons: ['Authenticate', 'Cancel', 'Disable for This Session'], + }, + authenticationTargets: [server.id], enabledChanges: [false], focused: true, cancelled: true, diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index c0659469232d..c23e01dcd5f9 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -2198,14 +2198,17 @@ suite('ChatResponseModel', () => { response.updateContent(tool); tool.setAuthenticationRequired({ id: 'server', name: 'Test MCP', resource: 'https://example.com/mcp' }); const authentication = pending(response); + tool.setAuthenticationRequired({ id: 'server', name: 'Test MCP (Connector)', resource: 'https://example.com/mcp' }); + const refreshedAuthentication = pending(response); tool.requestConfirmation({ confirmationMessages: { title: 'Approve', message: new MarkdownString('Confirm'), confirmResults: true } }); IChatToolInvocation.confirmWith(tool, { type: ToolConfirmKind.UserAction }); await tool.didExecuteTool({ content: [] }); const postApproval = pending(response); IChatToolInvocation.confirmWith(tool, { type: ToolConfirmKind.UserAction }); - assert.deepStrictEqual([authentication, postApproval, pending(response)], [ + assert.deepStrictEqual([authentication, refreshedAuthentication, postApproval, pending(response)], [ { detail: 'Authenticate Test MCP to continue...', tools: ['tool'] }, + { detail: 'Authenticate Test MCP (Connector) to continue...', tools: ['tool'] }, { detail: 'Approve tool result?', tools: ['tool'] }, { detail: undefined, tools: [] }, ]); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index 16d8f1df752d..aaecaf010848 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -53,7 +53,7 @@ import { IRemoteUserDataProfilesService } from '../../../services/userDataProfil import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { IChatWidgetService } from '../../chat/browser/chat.js'; -import { IAgentHostCustomizationService } from '../../chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { getMcpServerDisplayLabel, IAgentHostCustomizationService } from '../../chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; import { setAgentHostPluginEnablement } from '../../chat/browser/agentPluginActions.js'; import { IAICustomizationWorkspaceService } from '../../chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPluginService } from '../../chat/common/plugins/agentPluginService.js'; @@ -238,7 +238,7 @@ export class ListMcpServerCommand extends Action2 { } satisfies ItemType] : servers.map((server): ItemType => ({ id: server.id, server, - label: server.name, + label: getMcpServerDisplayLabel(server), description: server.enabled ? mcpServerStatusToLabel(server.status) : localize('mcp.disabled', 'Disabled'), @@ -373,9 +373,10 @@ async function runAgentHostMcpServerLifecycleAction(server: IAgentHostMcpServer, } catch (error) { services.logService.error(`Failed to ${action} MCP server '${server.name}'`, error); const message = error instanceof Error ? error.message : String(error); + const label = getMcpServerDisplayLabel(server); services.notificationService.error(action === 'start' - ? localize('mcp.agentHost.startError', "Failed to start MCP server '{0}': {1}", server.name, message) - : localize('mcp.agentHost.stopError', "Failed to stop MCP server '{0}': {1}", server.name, message)); + ? localize('mcp.agentHost.startError', "Failed to start MCP server '{0}': {1}", label, message) + : localize('mcp.agentHost.stopError', "Failed to stop MCP server '{0}': {1}", label, message)); } } @@ -546,7 +547,7 @@ export class McpAgentHostServerOptionsCommand extends Action2 { }); const picked = await quickInputService.pick(items, { - placeHolder: server.name, + placeHolder: getMcpServerDisplayLabel(server), }); if (!picked || !hasKey(picked, { action: true })) { diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatMcpAuthenticationContentPart.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatMcpAuthenticationContentPart.fixture.ts new file mode 100644 index 000000000000..bc1f41169bb8 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatMcpAuthenticationContentPart.fixture.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Event } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IAgentHostCustomizationService } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { ChatMcpAuthenticationContentPart } from '../../../../contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.js'; +import { IChatMcpAuthenticationRequired } from '../../../../contrib/chat/common/chatService/chatService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; + +import '../../../../contrib/chat/browser/widget/media/chat.css'; + +const sessionResource = URI.parse('chat-session://fixture/connector-authentication'); +const runtimeServerId = 'github-copilot-connector-94d26095770df60673dd'; + +function renderAuthenticationPart(context: ComponentFixtureContext): void { + type McpServer = ReturnType[number]; + + const server = new class extends mock() { + override readonly id = runtimeServerId; + override readonly name = runtimeServerId; + override readonly displayName = 'GitHub'; + override readonly enabled = true; + override readonly status = McpServerStatus.AuthRequired; + }(); + const customizationService = new class extends mock() { + override readonly onDidChangeCustomizations = Event.None; + override getMcpServers(): readonly McpServer[] { + return [server]; + } + }(); + const instantiationService = createEditorServices(context.disposableStore, { + colorTheme: context.theme, + additionalServices: registration => { + registration.define(IMarkdownRendererService, MarkdownRendererService); + registration.defineInstance(IAgentHostCustomizationService, customizationService); + }, + }); + const data: IChatMcpAuthenticationRequired = { + kind: 'mcpAuthenticationRequired', + sessionResource, + servers: observableValue('mcpAuthenticationServers', [{ + id: runtimeServerId, + name: runtimeServerId, + resource: 'https://api.github.com', + }]), + isUsed: false, + }; + const part = context.disposableStore.add(instantiationService.createInstance(ChatMcpAuthenticationContentPart, data, {})); + + context.container.style.width = '720px'; + context.container.style.padding = '16px'; + context.container.classList.add('interactive-session'); + const response = dom.$('.interactive-item-container.interactive-response'); + const value = dom.$('.value'); + value.appendChild(part.domNode); + response.appendChild(value); + context.container.appendChild(response); +} + +export default defineThemedFixtureGroup({ path: 'chat/' }, { + ConnectorAuthentication: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderAuthenticationPart, + }), +}); From 3355e668c289d49317b5dc65141263671d2295f0 Mon Sep 17 00:00:00 2001 From: Dileep Yavanmandha <52841896+dileepyavan@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:55:30 -0700 Subject: [PATCH 32/49] Improve Copilot Agent Host sandbox controls and policy display (#339632) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meta/agentSlashCommandOutputMeta.ts | 7 ++ .../meta/vscode/agentSandboxPolicyMeta.ts | 24 ++++-- .../vscode/agentSlashCommandOutputMeta.ts | 35 +++++++++ .../agentHost/common/sandboxConfigSchema.ts | 14 ++++ .../node/copilot/copilotAgentSession.ts | 21 +++++- .../node/copilot/copilotSandboxDiagnostics.ts | 3 +- .../node/copilot/copilotSandboxPolicy.ts | 4 + .../copilot/copilotSandboxPolicyDisplay.ts | 8 +- .../node/copilot/copilotSlashCommand.ts | 2 +- .../node/copilot/sandboxConfigForSdk.ts | 5 +- .../platform/agentHost/node/sessionSandbox.ts | 6 +- .../test/common/agentMetaReaders.test.ts | 15 ++++ .../test/node/copilotAgentSession.test.ts | 37 ++++++---- .../node/copilotSandboxDiagnostics.test.ts | 27 ++++++- .../test/node/copilotSessionLauncher.test.ts | 32 +++++++- ...ilotSlashCommandCompletionProvider.test.ts | 4 +- .../test/node/sandboxConfigForSdk.test.ts | 27 +++++++ .../test/node/sessionSandbox.test.ts | 51 ++++++++++++- .../policy/common/copilotManagedSettings.ts | 12 ++- .../common/copilotManagedSettings.test.ts | 16 +++- src/vs/platform/sandbox/common/settings.ts | 2 + .../agentHost/agentHostSessionHandler.ts | 55 ++++++++++++-- .../agentHost/stateToProgressAdapter.ts | 11 +++ .../agentHostChatContribution.test.ts | 74 +++++++++++++++++++ .../agentHostClientTools.test.ts | 2 + .../preferences/browser/settingsTreeModels.ts | 6 ++ .../test/browser/settingsTreeModels.test.ts | 16 ++++ .../common/sandboxSettingsReader.ts | 2 + .../terminalChatAgentToolsConfiguration.ts | 20 ++++- .../browser/agentHostSandboxForwarder.test.ts | 2 + .../test/common/sandboxSettingsReader.test.ts | 2 + ...erminalChatAgentToolsConfiguration.test.ts | 27 ++++++- 32 files changed, 518 insertions(+), 51 deletions(-) create mode 100644 src/vs/platform/agentHost/common/meta/agentSlashCommandOutputMeta.ts create mode 100644 src/vs/platform/agentHost/common/meta/vscode/agentSlashCommandOutputMeta.ts diff --git a/src/vs/platform/agentHost/common/meta/agentSlashCommandOutputMeta.ts b/src/vs/platform/agentHost/common/meta/agentSlashCommandOutputMeta.ts new file mode 100644 index 000000000000..97b72eef836d --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentSlashCommandOutputMeta.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type { ISlashCommandResource } from './vscode/agentSlashCommandOutputMeta.js'; +export { readSlashCommandResource, toSlashCommandResourceMeta } from './vscode/agentSlashCommandOutputMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentSandboxPolicyMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentSandboxPolicyMeta.ts index 97542935ee5f..3a5584e76b11 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/agentSandboxPolicyMeta.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/agentSandboxPolicyMeta.ts @@ -16,20 +16,30 @@ export interface ISessionSandboxPolicy { readonly allowDevToolAccess?: boolean; readonly sandboxMcpServers?: boolean; readonly sandboxLspServers?: boolean; + readonly authenticateGit?: boolean; + readonly authenticateGh?: boolean; readonly failClosed?: boolean; } -/** Missing or unsupported metadata carries no assertion about the host's policy. */ +/** Reads trusted host policy metadata; missing or non-object metadata carries no policy assertion. */ export function readSessionSandboxPolicy(source: { readonly _meta?: Record } | undefined): ISessionSandboxPolicy | undefined { const value = source?._meta?.[sandboxPolicyKey]; if (!isObject(value)) { return undefined; } - const { enabled, allowBypass, allowOutbound, allowLocalNetwork, allowDevToolAccess, sandboxMcpServers, sandboxLspServers, failClosed } = value as Record; - if (typeof enabled !== 'boolean' || (allowBypass !== undefined && typeof allowBypass !== 'boolean') || (allowOutbound !== undefined && typeof allowOutbound !== 'boolean') || (allowLocalNetwork !== undefined && typeof allowLocalNetwork !== 'boolean') || (allowDevToolAccess !== undefined && typeof allowDevToolAccess !== 'boolean') || (sandboxMcpServers !== undefined && typeof sandboxMcpServers !== 'boolean') || (sandboxLspServers !== undefined && typeof sandboxLspServers !== 'boolean') || (failClosed !== undefined && typeof failClosed !== 'boolean')) { - return undefined; - } - return { enabled, ...(allowBypass !== undefined ? { allowBypass } : {}), ...(allowOutbound !== undefined ? { allowOutbound } : {}), ...(allowLocalNetwork !== undefined ? { allowLocalNetwork } : {}), ...(allowDevToolAccess !== undefined ? { allowDevToolAccess } : {}), ...(sandboxMcpServers !== undefined ? { sandboxMcpServers } : {}), ...(sandboxLspServers !== undefined ? { sandboxLspServers } : {}), ...(failClosed !== undefined ? { failClosed } : {}) }; + const { enabled, allowBypass, allowOutbound, allowLocalNetwork, allowDevToolAccess, sandboxMcpServers, sandboxLspServers, authenticateGit, authenticateGh, failClosed } = value as ISessionSandboxPolicy; + return { + enabled, + ...(allowBypass !== undefined ? { allowBypass } : {}), + ...(allowOutbound !== undefined ? { allowOutbound } : {}), + ...(allowLocalNetwork !== undefined ? { allowLocalNetwork } : {}), + ...(allowDevToolAccess !== undefined ? { allowDevToolAccess } : {}), + ...(sandboxMcpServers !== undefined ? { sandboxMcpServers } : {}), + ...(sandboxLspServers !== undefined ? { sandboxLspServers } : {}), + ...(authenticateGit !== undefined ? { authenticateGit } : {}), + ...(authenticateGh !== undefined ? { authenticateGh } : {}), + ...(failClosed !== undefined ? { failClosed } : {}), + }; } export function withSessionSandboxPolicy(meta: Record | undefined, policy: ISessionSandboxPolicy | undefined): Record { @@ -43,6 +53,8 @@ export function withSessionSandboxPolicy(meta: Record | undefin ...(policy.allowDevToolAccess !== undefined ? { allowDevToolAccess: policy.allowDevToolAccess } : {}), ...(policy.sandboxMcpServers !== undefined ? { sandboxMcpServers: policy.sandboxMcpServers } : {}), ...(policy.sandboxLspServers !== undefined ? { sandboxLspServers: policy.sandboxLspServers } : {}), + ...(policy.authenticateGit !== undefined ? { authenticateGit: policy.authenticateGit } : {}), + ...(policy.authenticateGh !== undefined ? { authenticateGh: policy.authenticateGh } : {}), ...(policy.failClosed !== undefined ? { failClosed: policy.failClosed } : {}), } : undefined, }; diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentSlashCommandOutputMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentSlashCommandOutputMeta.ts new file mode 100644 index 000000000000..daddfb659488 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/vscode/agentSlashCommandOutputMeta.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isObject } from '../../../../../base/common/types.js'; +import { URI } from '../../../../../base/common/uri.js'; + +const slashCommandResourceKey = 'vscode.slashCommandResource'; + +export interface ISlashCommandResource { + readonly resource: URI; + readonly preview: boolean; +} + +/** Reads an optional resource to open for a user-invoked slash command. */ +export function readSlashCommandResource(source: { readonly _meta?: Record }): ISlashCommandResource | undefined { + const value = source._meta?.[slashCommandResourceKey]; + if (!isObject(value)) { + return undefined; + } + const { resource, preview } = value as Record; + if (typeof resource !== 'string' || typeof preview !== 'boolean') { + return undefined; + } + try { + return { resource: URI.parse(resource, true), preview }; + } catch { + return undefined; + } +} + +export function toSlashCommandResourceMeta(resource: URI, preview: boolean): Record { + return { [slashCommandResourceKey]: { resource: resource.toString(), preview } }; +} diff --git a/src/vs/platform/agentHost/common/sandboxConfigSchema.ts b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts index 6dd55b2bd595..ae7bff150697 100644 --- a/src/vs/platform/agentHost/common/sandboxConfigSchema.ts +++ b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts @@ -32,6 +32,8 @@ export const enum AgentHostSandboxKey { AllowUnsandboxedCommands = 'allowUnsandboxedCommands', SandboxMcpServers = 'sandboxMcpServers', SandboxLspServers = 'sandboxLspServers', + AuthenticateGit = 'authenticateGit', + AuthenticateGh = 'authenticateGh', AllowDevToolAccess = 'allowDevToolAccess', UserConfiguredPaths = 'fileSystem.userConfiguredPaths', LinuxFileSystem = 'fileSystem.linux', @@ -50,6 +52,8 @@ export type ISandboxConfigValue = Partial<{ [AgentHostSandboxKey.AllowUnsandboxedCommands]: boolean; [AgentHostSandboxKey.SandboxMcpServers]: boolean; [AgentHostSandboxKey.SandboxLspServers]: boolean; + [AgentHostSandboxKey.AuthenticateGit]: boolean; + [AgentHostSandboxKey.AuthenticateGh]: boolean; [AgentHostSandboxKey.AllowDevToolAccess]: boolean; [AgentHostSandboxKey.UserConfiguredPaths]: IAgentSandboxUserConfiguredPaths; [AgentHostSandboxKey.LinuxFileSystem]: IAgentSandboxFileSystemSetting; @@ -97,6 +101,14 @@ export const sandboxConfigSchema = createSchema({ type: 'boolean', title: localize('agentHost.config.sandbox.sandboxLspServers.title', "Sandbox LSP Servers"), }, + [AgentHostSandboxKey.AuthenticateGit]: { + type: 'boolean', + title: localize('agentHost.config.sandbox.authenticateGit.title', "Authenticate git"), + }, + [AgentHostSandboxKey.AuthenticateGh]: { + type: 'boolean', + title: localize('agentHost.config.sandbox.authenticateGh.title', "Authenticate gh"), + }, [AgentHostSandboxKey.AllowDevToolAccess]: { type: 'boolean', title: localize('agentHost.config.sandbox.allowDevToolAccess.title', "Allow Dev Tool Access"), @@ -166,6 +178,8 @@ export const sandboxSettingIdToAgentHostKey: Readonly): void { + const turn = this._currentTurn.value; + if (!meta || !turn) { + this._emitMarkdownDelta(content, undefined, true); + return; + } + this._emitAction({ + type: ActionType.ChatResponsePart, + turnId: turn.id, + part: { kind: ResponsePartKind.SystemNotification, content: { markdown: content }, _meta: meta }, + }, undefined, true); + } + /** Emits a reasoning delta, similar to {@link _emitMarkdownDelta} but for reasoning parts. */ private _emitReasoningDelta(content: string, parentToolCallId?: string): void { if (parentToolCallId === undefined && this._shouldDropLateRootTurnEvent('assistant.reasoning_delta')) { @@ -3828,14 +3842,17 @@ export class CopilotAgentSession extends Disposable { } const output = await runtimeSlashCommand.getOutput?.(slashCommand.rest, result); const renderedOutput = output ? renderCopilotSlashCommandOutput(output) : undefined; + const outputMeta = output?.kind === 'link' && output.openInEditor + ? toSlashCommandResourceMeta(output.resource, output.preview === true) + : undefined; switch (result.kind) { case 'text': - this._emitMarkdownDelta(renderedOutput ?? (result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)), undefined, true); + this._emitSlashCommandOutput(renderedOutput ?? (result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)), outputMeta); break; case 'completed': { const message = renderedOutput ?? result.message; if (message) { - this._emitMarkdownDelta(message, undefined, true); + this._emitSlashCommandOutput(message, outputMeta); } break; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSandboxDiagnostics.ts b/src/vs/platform/agentHost/node/copilot/copilotSandboxDiagnostics.ts index ab0074ae2bdf..6c1425c145e9 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSandboxDiagnostics.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSandboxDiagnostics.ts @@ -45,8 +45,9 @@ export class CopilotSandboxDiagnostics extends Disposable { const network = config.userPolicy?.network; reasons = support.capabilities.filter(capability => !capability.supported && ( capability.name === 'shell' + || capability.name === 'filesystem_enumeration' || capability.name === 'network' && network?.allowOutbound === true - || capability.name === 'network_filtering' && network?.proxy !== undefined + || capability.name === 'network_filtering' || capability.name === 'denied_paths' && !!config.userPolicy?.filesystem?.deniedPaths?.length )).map(capability => capability.reason || localize('copilot.sandbox.capabilityUnavailable', "The sandbox capability '{0}' is unavailable on this host.", capability.name)); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts b/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts index 1a89bf5c573a..d42e0b217a79 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts @@ -30,6 +30,8 @@ export function projectCopilotSandboxPolicy(data: Extract { +export function getSessionSandboxOverrides(configuration: IAgentConfigurationService, session: string): Pick { session = resolveAgentHostSession(URI.parse(session)).toString(); const raw = configuration.getSessionConfigValues(session)?.[SessionConfigKey.SandboxEnabled]; const selection = platformSessionSchema.validate(SessionConfigKey.SandboxEnabled, raw) ? raw : undefined; @@ -54,6 +54,8 @@ export function getSessionSandboxOverrides(configuration: IAgentConfigurationSer const allowDevToolAccess = SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, policy?.allowDevToolAccess); const sandboxMcpServers = SandboxSettingsResolutionHelper.resolveSandboxServers(undefined, policy?.sandboxMcpServers); const sandboxLspServers = SandboxSettingsResolutionHelper.resolveSandboxServers(undefined, policy?.sandboxLspServers); + const authenticateGit = SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, policy?.authenticateGit); + const authenticateGh = SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, policy?.authenticateGh); return { ...(enabled !== undefined ? { [AgentHostSandboxKey.Enabled]: enabled, @@ -64,5 +66,7 @@ export function getSessionSandboxOverrides(configuration: IAgentConfigurationSer ...(allowDevToolAccess !== undefined ? { [AgentHostSandboxKey.AllowDevToolAccess]: allowDevToolAccess } : {}), ...(sandboxMcpServers !== undefined ? { [AgentHostSandboxKey.SandboxMcpServers]: sandboxMcpServers } : {}), ...(sandboxLspServers !== undefined ? { [AgentHostSandboxKey.SandboxLspServers]: sandboxLspServers } : {}), + ...(authenticateGit !== undefined ? { [AgentHostSandboxKey.AuthenticateGit]: authenticateGit } : {}), + ...(authenticateGh !== undefined ? { [AgentHostSandboxKey.AuthenticateGh]: authenticateGh } : {}), }; } diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index ea23f0b22064..a2f54c305e61 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -9,6 +9,7 @@ import { type AgentFusionPhaseStatus, isPresentationOnlyToolCall, readToolCallMe import { AgentSystemNotificationKind, type AgentFusionProgressStatus, readAgentSystemNotificationMeta, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { readSessionSandboxPolicy, withSessionSandboxPolicy } from '../../common/meta/agentSandboxPolicyMeta.js'; +import { readSlashCommandResource, toSlashCommandResourceMeta } from '../../common/meta/agentSlashCommandOutputMeta.js'; import { readChatInputState, withChatInputState } from '../../common/meta/agentHostChatInputState.js'; import { createEditorInlineChatInstruction, createTerminalChatInstruction, readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { readAgentCustomizationMeta, toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; @@ -51,6 +52,20 @@ suite('Agent host _meta readers', () => { ], [restrictions, undefined, undefined, undefined]); }); + test('validates slash command resource metadata', () => { + const resource = URI.parse('agenthost-content:///reports/sandbox-policy.md'); + assert.deepStrictEqual([ + readSlashCommandResource({ _meta: toSlashCommandResourceMeta(resource, true) }), + ...[undefined, null, [], {}, { resource: 1, preview: true }, { resource: resource.toString(), preview: 'true' }, { resource: '/reports/policy.md', preview: true }] + .map(value => readSlashCommandResource({ _meta: { 'vscode.slashCommandResource': value } })), + readSlashCommandResource({}), + ].map(value => value ? { resource: value.resource.toString(), preview: value.preview } : undefined), [ + { resource: resource.toString(), preview: true }, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, + ]); + }); + suite('session sandbox policy', () => { const policies = [ undefined, diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 6c24600972b7..f4773b40fa5f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -44,6 +44,7 @@ import { agentModelCallMetaKey, readAgentModelCallDiagnostics } from '../../comm import { readAgentRuntimeModelConfiguration } from '../../common/meta/agentModelConfigurationMeta.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, readAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; import { readAgentSandboxDiagnostics } from '../../common/meta/agentSandboxDiagnostics.js'; +import { toSlashCommandResourceMeta } from '../../common/meta/agentSlashCommandOutputMeta.js'; import { toSessionEvents } from './copilotTestEvents.js'; import { fusionTestData } from './copilotFusionTestEvents.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; @@ -4670,7 +4671,7 @@ suite('CopilotAgentSession', () => { content: 'Sandbox is disabled.', }, ] satisfies { name: string; result: MockCopilotSession['commandInvokeResult']; content: string }[]) { - test(`links to a Markdown file containing ${name} without a model turn`, async () => { + test(`provides a Markdown file to open containing ${name} without a model turn`, async () => { const { session, mockSession, signals, storedFileContents } = await createSandboxSession(); mockSession.commandInvokeResult = result; @@ -4678,7 +4679,7 @@ suite('CopilotAgentSession', () => { const actions = getActions(signals); const [resource] = storedFileContents.keys(); - assert.match(resource, /^inmemory:\/session-data\/test-session-1\/diagnostics\/[\da-f-]+\/sandbox-policy\.md$/); + assert.strictEqual(resource, 'inmemory:/session-data/test-session-1/diagnostics/sandbox-policy.md'); assert.deepStrictEqual({ commandListCalls: mockSession.commandListCalls, commandInvokeCalls: mockSession.commandInvokeCalls, @@ -4686,7 +4687,10 @@ suite('CopilotAgentSession', () => { files: [...storedFileContents], responseParts: actions .filter(a => a.type === ActionType.ChatResponsePart) - .map(a => a.part.kind === ResponsePartKind.Markdown ? a.part.content : a.part.kind), + .map(a => a.part.kind === ResponsePartKind.SystemNotification ? a.part.content : a.part.kind), + responseMetadata: actions + .filter(a => a.type === ActionType.ChatResponsePart) + .map(a => a.part.kind === ResponsePartKind.SystemNotification ? a.part._meta : undefined), turnComplete: actions .filter(a => a.type === ActionType.ChatTurnComplete) .map(a => a.turnId), @@ -4696,14 +4700,15 @@ suite('CopilotAgentSession', () => { commandInvokeCalls: [{ name: 'sandbox', input: 'policy' }], sendRequests: [], files: [[resource, content]], - responseParts: [`[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)`], + responseParts: [{ markdown: `[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)` }], + responseMetadata: [toSlashCommandResourceMeta(URI.parse(resource), true)], turnComplete: ['turn-policy'], hasActiveTurn: false, }); }); } - test('preserves previous policy snapshots across invocations and session disposal', async () => { + test('keeps only the latest policy in the same file across invocations and session disposal', async () => { const { session, mockSession, signals, storedFileContents } = await createSandboxSession(); mockSession.commandInvokeResult = { kind: 'text', text: '# First policy', markdown: true }; await session.send('/sandbox policy', undefined, 'turn-policy-1'); @@ -4711,15 +4716,15 @@ suite('CopilotAgentSession', () => { await session.send('/sandbox policy', undefined, 'turn-policy-2'); session.dispose(); - const resources = [...storedFileContents.keys()]; + const resource = 'inmemory:/session-data/test-session-1/diagnostics/sandbox-policy.md'; assert.deepStrictEqual({ - contents: [...storedFileContents.values()], + files: [...storedFileContents], responseParts: getActions(signals) .filter(a => a.type === ActionType.ChatResponsePart) - .map(a => a.part.kind === ResponsePartKind.Markdown ? a.part.content : a.part.kind), + .map(a => a.part.kind === ResponsePartKind.SystemNotification ? a.part.content : a.part.kind), }, { - contents: ['# First policy', '# Second policy'], - responseParts: resources.map(resource => `[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)`), + files: [[resource, '# Second policy']], + responseParts: [1, 2].map(() => ({ markdown: `[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)` })), }); }); @@ -4815,11 +4820,11 @@ suite('CopilotAgentSession', () => { contents: [...storedFileContents.values()], responseParts: getActions(signals) .filter(a => a.type === ActionType.ChatResponsePart) - .map(a => a.part.kind === ResponsePartKind.Markdown ? a.part.content : a.part.kind), + .map(a => a.part.kind === ResponsePartKind.SystemNotification ? a.part.content : a.part.kind), }, { commandInvokeCalls: [{ name: 'sandbox', input: 'policy ' }], contents: ['Sandbox is disabled.'], - responseParts: [...storedFileContents.keys()].map(resource => `[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)`), + responseParts: [...storedFileContents.keys()].map(resource => ({ markdown: `[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)` })), }); }); @@ -10076,10 +10081,12 @@ suite('CopilotAgentSession', () => { [AgentHostSandboxKey.SandboxLspServers, true], [AgentHostSandboxKey.AllowDevToolAccess, false], [AgentHostSandboxKey.AllowLocalNetwork, false], + [AgentHostSandboxKey.AuthenticateGit, false], + [AgentHostSandboxKey.AuthenticateGh, false], ] as const) { test(`per-request sandbox: enforces ${key} and restores local choices on ${platform}`, async () => { for (const local of [false, true]) { - const sandboxPolicy: { enabled: boolean; sandboxMcpServers?: boolean; sandboxLspServers?: boolean; allowDevToolAccess?: boolean; allowLocalNetwork?: boolean } = { enabled: true }; + const sandboxPolicy: { enabled: boolean; sandboxMcpServers?: boolean; sandboxLspServers?: boolean; allowDevToolAccess?: boolean; allowLocalNetwork?: boolean; authenticateGit?: boolean; authenticateGh?: boolean } = { enabled: true }; const { session, mockSession } = await createAgentSession(disposables, { platform, sandboxPolicy, @@ -10095,7 +10102,9 @@ suite('CopilotAgentSession', () => { sandboxPolicy[key] = managed; await session.send('hello', undefined, `turn-${index}`); const applied = mockSession.sandboxConfigUpdates.at(-1) as SandboxConfig; - values.push(key === AgentHostSandboxKey.AllowLocalNetwork ? applied.userPolicy?.network?.allowLocalNetwork : applied[key]); + values.push(key === AgentHostSandboxKey.AuthenticateGit ? applied.auth?.git + : key === AgentHostSandboxKey.AuthenticateGh ? applied.auth?.gh + : key === AgentHostSandboxKey.AllowLocalNetwork ? applied.userPolicy?.network?.allowLocalNetwork : applied[key]); } assert.deepStrictEqual(values, [managedValue, local, local]); } diff --git a/src/vs/platform/agentHost/test/node/copilotSandboxDiagnostics.test.ts b/src/vs/platform/agentHost/test/node/copilotSandboxDiagnostics.test.ts index 3fc79d644504..f2e3e7e19284 100644 --- a/src/vs/platform/agentHost/test/node/copilotSandboxDiagnostics.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSandboxDiagnostics.test.ts @@ -50,7 +50,7 @@ suite('CopilotSandboxDiagnostics', () => { ]); }); - test('only reports capabilities used by the configured sandbox', async () => { + test('reports network filtering without a proxy and gates network and denied paths on configuration', async () => { const manager = createStateManager(); const diagnostics = store.add(new CopilotSandboxDiagnostics(session, async () => ({ supported: true, @@ -72,11 +72,34 @@ suite('CopilotSandboxDiagnostics', () => { }, }); assert.deepStrictEqual([unused, readAgentSandboxDiagnostics(manager.getSessionSummary(session)!)], [ - undefined, + ['Proxy unsupported.'], ['Install slirp4netns.', 'Proxy unsupported.', 'Denied paths unsupported.'], ]); }); + test('reports unavailable network filtering and filesystem enumeration from host capabilities without user policy', async () => { + const manager = createStateManager(); + const networkFilteringReason = 'This Windows host cannot run a sandbox proxy. See https://aka.ms/ghcp-sandbox-os-support'; + const filesystemEnumerationReason = 'This Windows host cannot grant enumerate-only filesystem access. See https://aka.ms/ghcp-sandbox-os-support'; + const diagnostics = store.add(new CopilotSandboxDiagnostics(session, async () => ({ + supported: true, + capabilities: [ + { name: 'network', supported: true }, + { name: 'network_filtering', supported: false, reason: networkFilteringReason }, + { name: 'denied_paths', supported: true }, + { name: 'shell', supported: true }, + { name: 'filesystem_enumeration', supported: false, reason: filesystemEnumerationReason }, + ], + }), manager, new NullLogService())); + + await diagnostics.update({ enabled: true }); + + assert.deepStrictEqual(readAgentSandboxDiagnostics(manager.getSessionSummary(session)!), [ + networkFilteringReason, + filesystemEnumerationReason, + ]); + }); + test('reports unavailable shell support and supplies missing diagnostic text', async () => { const manager = createStateManager(); const diagnostics = store.add(new CopilotSandboxDiagnostics(session, async () => ({ diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 87dc9106c2a3..4a099798883b 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -151,7 +151,7 @@ function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettin suite('CopilotSessionLauncher sandbox policy', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function setup(kind: 'create' | 'resume', reportPolicy = true, enforced = false, allowBypass = false, allowOutbound?: boolean, sandboxUpdateError?: Error, sandboxToggles?: Pick, allowLocalNetwork?: boolean) { + function setup(kind: 'create' | 'resume', reportPolicy = true, enforced = false, allowBypass = false, allowOutbound?: boolean, sandboxUpdateError?: Error, sandboxToggles?: Pick, allowLocalNetwork?: boolean) { const manager = store.add(new AgentHostStateManager(new NullLogService())); const configuration = store.add(new AgentConfigurationService(manager, new NullLogService())); const owner = 'copilot:/sess-1'; @@ -177,6 +177,11 @@ suite('CopilotSessionLauncher sandbox policy', () => { if (allowLocalNetwork === false && options.sandboxConfig?.userPolicy?.network?.allowLocalNetwork === true) { throw new Error('Local network access violates managed policy'); } + for (const field of ['git', 'gh'] as const) { + if (sandboxToggles?.auth?.[field] === false && options.sandboxConfig?.auth?.[field] === true) { + throw new Error(`${field} authentication violates managed policy`); + } + } for (const [key, managedValue] of [['sandboxMcpServers', true], ['sandboxLspServers', true], ['allowDevToolAccess', false]] as const) { if (sandboxToggles?.[key] === managedValue && options.sandboxConfig && options.sandboxConfig[key] !== managedValue) { throw new Error(`${key} violates managed policy`); @@ -188,13 +193,14 @@ suite('CopilotSessionLauncher sandbox policy', () => { } }, } as unknown as CopilotSession; + const { auth, ...sandboxOptions } = sandboxToggles ?? {}; const initialize = (config: ResumeSessionConfig | undefined) => { captured = config; if (reportPolicy) { config?.onEvent?.({ id: 'resolved', parentId: null, timestamp: '2026-01-01T00:00:00Z', type: 'session.managed_settings_resolved', ephemeral: true, - data: { source: 'server', serverManaged: true, deviceManaged: false, failClosed: false, bypassPermissionsDisabled: false, managedKeys: enforced ? ['sandbox'] : [], settings: enforced ? { sandbox: { enabled: true, allowBypass, ...sandboxToggles, userPolicy: { network: { ...(allowOutbound !== undefined ? { allowOutbound } : {}), ...(allowLocalNetwork !== undefined ? { allowLocalNetwork } : {}) } } } } : {} }, + data: { source: 'server', serverManaged: true, deviceManaged: false, failClosed: false, bypassPermissionsDisabled: false, managedKeys: enforced ? ['sandbox'] : [], settings: enforced ? { sandbox: { enabled: true, allowBypass, ...sandboxOptions, ...(auth ? { auth: { ...auth } } : {}), userPolicy: { network: { ...(allowOutbound !== undefined ? { allowOutbound } : {}), ...(allowLocalNetwork !== undefined ? { allowLocalNetwork } : {}) } } } } : {} }, }); } return raw; @@ -217,6 +223,28 @@ suite('CopilotSessionLauncher sandbox policy', () => { } for (const kind of ['create', 'resume'] as const) { + for (const [key, field] of [ + ['authenticateGit', 'git'], + ['authenticateGh', 'gh'], + ] as const) { + test(`${kind} applies managed ${field} authentication denial and preserves local restrictions`, async () => { + for (const local of [undefined, false, true]) { + for (const managed of [undefined, false, true]) { + const fixture = setup(kind, true, true, false, undefined, undefined, { auth: { [field]: managed } }); + fixture.configuration.updateRootConfig({ sandbox: { enabled: 'on', ...(local !== undefined ? { [key]: local } : {}) } }); + store.add(await fixture.launcher.launch(fixture.plan, testRuntime)); + assert.deepStrictEqual({ + applied: fixture.updates.filter(update => update.sandboxConfig).map(update => update.sandboxConfig?.auth), + stored: fixture.configuration.getRootConfigValues()?.sandbox, + }, { + applied: [{ git: true, gh: true, [field]: managed === false ? false : local ?? true }], + stored: { enabled: 'on', ...(local !== undefined ? { [key]: local } : {}) }, + }); + } + } + }); + } + for (const [format, error] of [ ['structured', Object.assign(new Error('Managed sandbox conflict'), { data: { code: 'managed_sandbox_policy_conflict' } })], ['message', new Error('Sandbox configuration update violates managed policy. Contact your administrator for more information.')], diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index ad46d1debb25..c7fea634b4ad 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -253,7 +253,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { label: 'sandbox policy', _meta: { command: 'sandbox', - description: 'Show the effective sandbox policy for this session', + description: 'Open the latest effective sandbox policy for this session in the editor', }, }, }]); @@ -294,7 +294,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { const commands = new CopilotSlashCommandProvider(async () => [sandboxCommand], undefined, new NullLogService()); assert.deepStrictEqual((await commands.getSlashCommands()).map(command => ({ name: command.name, input: command.input, - })), [{ name: 'sandbox', input: { hint: '', choices: [{ name: 'policy', description: 'Show the effective sandbox policy for this session' }] } }]); + })), [{ name: 'sandbox', input: { hint: '', choices: [{ name: 'policy', description: 'Open the latest effective sandbox policy for this session in the editor' }] } }]); }); test('offers only policy even when the SDK only advertises configuration choices', async () => { diff --git a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts index 1d78dc665367..7797670fa5d4 100644 --- a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts +++ b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts @@ -217,6 +217,33 @@ suite('buildSandboxConfigForSdk', () => { }); }); + test('defaults credential authentication to true and respects independent choices on every platform', () => { + for (const platform of ['linux', 'darwin', 'win32'] as const) { + for (const authenticateGit of [undefined, false, true]) { + for (const authenticateGh of [undefined, false, true]) { + const config: ISandboxConfigValue = { + [AgentHostSandboxKey.AuthenticateGit]: authenticateGit, + [AgentHostSandboxKey.AuthenticateGh]: authenticateGh, + }; + assert.deepStrictEqual([ + buildSandboxConfigForSdk(platform, { ...config, enabled: AgentSandboxEnabledValue.On }), + buildSandboxConfigForSdk(platform, { ...config, enabled: AgentSandboxEnabledValue.Off }), + ], [ + { ...expectedSandboxConfig(), auth: { git: authenticateGit ?? true, gh: authenticateGh ?? true } }, + undefined, + ]); + } + } + } + }); + + test('validates credential preferences as optional boolean host settings', () => { + for (const key of [AgentHostSandboxKey.AuthenticateGit, AgentHostSandboxKey.AuthenticateGh]) { + assert.deepStrictEqual([{}, { [key]: true }, { [key]: false }, { [key]: 'false' }, { [key]: null }] + .map(config => sandboxConfigSchema.validate(AgentHostSandboxConfigKey.Sandbox, config)), [true, true, true, false, false]); + } + }); + }); suite('filesystem policy', () => { diff --git a/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts b/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts index b09a4c9838ce..34b5bf556fb7 100644 --- a/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts @@ -80,8 +80,8 @@ suite('Session sandbox configuration', () => { }); }); - test('ignores missing or malformed sandbox policy metadata', () => { - const values = [undefined, null, [], true, {}, { enabled: 'true' }, { enabled: true, allowBypass: 'false' }, { enabled: true, allowOutbound: 'false' }, { enabled: true, allowLocalNetwork: 'false' }, { enabled: true, allowDevToolAccess: 'false' }, { enabled: true, sandboxMcpServers: 'true' }, { enabled: true, sandboxLspServers: 'true' }]; + test('ignores missing or non-object sandbox policy metadata', () => { + const values = [undefined, null, [], true, 'policy', 0]; assert.deepStrictEqual(values.map(value => readSessionSandboxPolicy({ _meta: { 'vscode.resolvedSandboxPolicy': value } })), values.map(() => undefined)); }); @@ -348,6 +348,53 @@ suite('Session sandbox configuration', () => { ]); }); + for (const [key, field] of [ + [AgentHostSandboxKey.AuthenticateGit, 'git'], + [AgentHostSandboxKey.AuthenticateGh, 'gh'], + ] as const) { + test(`resolved ${key} policy survives serialization and clears when omitted or malformed`, () => { + const { manager, configuration, create } = setupSession(); + const owner = create('credentials'); + const apply = (value: boolean | string | undefined) => { + configuration.setSessionSandboxPolicy(owner, projectCopilotSandboxPolicy({ + source: 'server', serverManaged: true, deviceManaged: false, + failClosed: false, bypassPermissionsDisabled: false, managedKeys: ['sandbox'], + settings: { sandbox: { enabled: true, allowBypass: false, auth: value === undefined ? {} : { [field]: value } } }, + }, owner, new NullLogService())); + return readSessionSandboxPolicy(JSON.parse(JSON.stringify(manager.getSessionState(owner)))); + }; + assert.deepStrictEqual([true, false, undefined, 'true'].map(apply), [ + { enabled: true, allowBypass: false, [key]: true }, + { enabled: true, allowBypass: false, [key]: false }, + { enabled: true, allowBypass: false }, + { enabled: true, allowBypass: false }, + ]); + }); + + test(`managed ${key} denial wins without widening local choices for owners, peers and subagents`, () => { + const { manager, configuration, create } = setupSession(); + const owner = create('credentials'); + const peer = buildChatUri(owner, 'peer'); + manager.addChat(owner, peer); + for (const local of [undefined, false, true]) { + const sandbox = { enabled: 'on', ...(local !== undefined ? { [key]: local } : {}) }; + configuration.updateRootConfig({ sandbox }); + for (const managed of [undefined, false, true, false, undefined]) { + configuration.setSessionSandboxPolicy(owner, { enabled: false, [key]: managed }); + const sessions = [owner, peer, buildSubagentSessionUri(owner, 'child')]; + assert.deepStrictEqual({ + values: sessions.map(session => (['linux', 'darwin', 'win32'] as const).map(platform => + buildSandboxConfigForSdk(platform, getSessionSandboxConfig(configuration, session))?.auth?.[field])), + stored: configuration.getRootConfigValues()?.sandbox, + }, { + values: sessions.map(() => (['linux', 'darwin', 'win32'] as const).map(() => managed === false ? false : local ?? true)), + stored: sandbox, + }); + } + } + }); + } + for (const [key, managedValue] of [ [AgentHostSandboxKey.SandboxMcpServers, true], [AgentHostSandboxKey.SandboxLspServers, true], diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index d796c25dd637..bedb6b1ec02e 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -75,6 +75,12 @@ export const COPILOT_SANDBOX_MCP_SERVERS_KEY = 'sandbox.sandboxMcpServers'; /** Managed-settings key that requires LSP servers to remain sandboxed. */ export const COPILOT_SANDBOX_LSP_SERVERS_KEY = 'sandbox.sandboxLspServers'; +/** Managed-settings key that restricts HTTPS Git authentication inside the sandbox. */ +export const COPILOT_SANDBOX_AUTH_GIT_KEY = 'sandbox.auth.git'; + +/** Managed-settings key that restricts GitHub CLI authentication inside the sandbox. */ +export const COPILOT_SANDBOX_AUTH_GH_KEY = 'sandbox.auth.gh'; + /** Managed-settings key that permits explicitly bypassing the sandbox. */ export const COPILOT_SANDBOX_ALLOW_BYPASS_KEY = 'sandbox.allowBypass'; @@ -99,6 +105,8 @@ export const MANAGED_SETTINGS_CONTROL_DEFINITIONS: IManagedSettingsPolicyDefinit [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_MCP_SERVERS_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_LSP_SERVERS_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_AUTH_GIT_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_AUTH_GH_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ALLOW_BYPASS_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY]: { type: 'boolean' }, @@ -458,7 +466,7 @@ export interface IManagedSettingsPick { * a lower channel. Telemetry instead selects the highest-priority block in its entirety, including * empty or unrecognized server/file object blocks; native delivery observes declared flat keys only. * Omitted leaves cannot inherit from a weaker managed source. - * Sandbox enablement is force-on-wins; bypass and outbound access are deny-wins so UI consumers + * Sandbox enablement is force-on-wins; bypass, access and credential authentication are deny-wins so UI consumers * cannot discard a sandbox restriction from another channel. * * The parameter order matches the precedence so call sites read top-to-bottom. Centralizing the @@ -499,7 +507,7 @@ export function pickManagedSettings(nativeMdm: ManagedSettingsData | undefined, if (existing) { existing.contributions.push({ channel, value }); if (((key === COPILOT_SANDBOX_ENABLED_KEY || key === COPILOT_SANDBOX_MCP_SERVERS_KEY || key === COPILOT_SANDBOX_LSP_SERVERS_KEY) && value === true && existing.value !== true) - || ((key === COPILOT_SANDBOX_ALLOW_BYPASS_KEY || key === COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY || key === COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY || key === COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY) && value === false && existing.value !== false)) { + || ((key === COPILOT_SANDBOX_ALLOW_BYPASS_KEY || key === COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY || key === COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY || key === COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY || key === COPILOT_SANDBOX_AUTH_GIT_KEY || key === COPILOT_SANDBOX_AUTH_GH_KEY) && value === false && existing.value !== false)) { existing.value = value; existing.source = channel; } diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index dff6be4b27c1..c286959936af 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -8,7 +8,7 @@ import { IStringDictionary } from '../../../../base/common/collections.js'; import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { ManagedSettingsData } from '../../../../base/common/policy.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_SANDBOX_LSP_SERVERS_KEY, COPILOT_SANDBOX_MCP_SERVERS_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, MANAGED_SETTINGS_CONTROL_DEFINITIONS, hasManagedSettingsDefinitions, managedModelValue, managedSettingsDisabledValue, managedSettingValue, normalizeManagedSettings, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; +import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_AUTH_GH_KEY, COPILOT_SANDBOX_AUTH_GIT_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_SANDBOX_LSP_SERVERS_KEY, COPILOT_SANDBOX_MCP_SERVERS_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, MANAGED_SETTINGS_CONTROL_DEFINITIONS, hasManagedSettingsDefinitions, managedModelValue, managedSettingsDisabledValue, managedSettingValue, normalizeManagedSettings, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; import { PolicyDefinition } from '../../common/policy.js'; suite('Copilot managed settings projection', () => { @@ -447,7 +447,7 @@ suite('Copilot managed settings precedence (pickManagedSettings)', () => { }); } - for (const key of [COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY]) { + for (const key of [COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_AUTH_GIT_KEY, COPILOT_SANDBOX_AUTH_GH_KEY]) { test(`${key} is deny-wins across every channel combination`, () => { const values = [undefined, false, true]; for (const native of values) { @@ -517,6 +517,18 @@ suite('Copilot managed settings precedence (pickManagedSettings)', () => { ]); }); + for (const [field, key] of [['git', COPILOT_SANDBOX_AUTH_GIT_KEY], ['gh', COPILOT_SANDBOX_AUTH_GH_KEY]]) { + test(`sandbox ${field} authentication projects only canonical nested boolean values`, () => { + assert.deepStrictEqual([false, true, 'false'].map(value => projectManagedSettings( + normalizeManagedSettings({ sandbox: { auth: { [field]: value } } }), MANAGED_SETTINGS_CONTROL_DEFINITIONS, + )), [ + { [key]: false }, + { [key]: true }, + {}, + ]); + }); + } + test('sandbox local network access projects only canonical nested boolean values', () => { assert.deepStrictEqual([false, true, 'false'].map(allowLocalNetwork => projectManagedSettings( normalizeManagedSettings({ sandbox: { userPolicy: { network: { allowLocalNetwork } } } }), MANAGED_SETTINGS_CONTROL_DEFINITIONS, diff --git a/src/vs/platform/sandbox/common/settings.ts b/src/vs/platform/sandbox/common/settings.ts index 36a93a0ed94f..478b61953c73 100644 --- a/src/vs/platform/sandbox/common/settings.ts +++ b/src/vs/platform/sandbox/common/settings.ts @@ -34,6 +34,8 @@ export const enum AgentSandboxSettingId { AgentSandboxAllowUnsandboxedCommands = 'chat.agent.sandbox.allowUnsandboxedCommands', AgentSandboxMcpServers = 'chat.agent.sandbox.mcpServers', AgentSandboxLspServers = 'chat.agent.sandbox.lspServers', + AgentSandboxAuthenticateGit = 'chat.agent.sandbox.credentials.authenticategit', + AgentSandboxAuthenticateGh = 'chat.agent.sandbox.credentials.authenticategh', AgentSandboxAllowDevToolAccess = 'chat.agent.sandbox.fileSystem.allowDevToolAccess', AgentSandboxUserConfiguredPaths = 'chat.agent.sandbox.fileSystem.userConfiguredPaths', AgentSandboxRetryWithAllowNetworkRequests = 'chat.agent.sandbox.retryWithAllowNetworkRequests', diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 24f814827a96..5ab58c0722f0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -45,6 +45,7 @@ import { filterSessionConfigValues } from '../../../../../../platform/agentHost/ import { readAttachmentDetail, withAttachmentDetail } from '../../../../../../platform/agentHost/common/meta/attachmentMeta.js'; import { AgentPermissionDecisionSource, readAgentPermissionResponseMeta, toAgentPermissionResponseMeta } from '../../../../../../platform/agentHost/common/meta/agentPermissionResponseMeta.js'; import { readCompletionAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentCompletionAttachmentMeta.js'; +import { readSlashCommandResource, type ISlashCommandResource } from '../../../../../../platform/agentHost/common/meta/agentSlashCommandOutputMeta.js'; import { IRemoteAgentHostService } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { resolveAgentHostSessionTrustFolders } from '../../../../../../platform/agentHost/common/agentHostWorkspaceTrust.js'; @@ -68,6 +69,7 @@ import { packErrorForTelemetry } from '../../../../../../platform/telemetry/comm import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; @@ -248,6 +250,7 @@ interface IObserveTurnOptions { readonly requireActiveTurn?: boolean; readonly onTurnEnded?: (lastTurn: Turn | undefined) => void; readonly onResponseText?: (text: string) => void; + readonly onOpenResponseResource?: (resource: ISlashCommandResource) => void; /** * When set, a failed turn does NOT emit its error as a markdown progress * part. The caller surfaces it instead as the agent result's @@ -1264,6 +1267,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IOpenerService private readonly _openerService: IOpenerService, + @IEditorService private readonly _editorService: IEditorService, @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, @@ -3326,7 +3330,24 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // surfaced through the same path: the observer disposes itself when // `cancellationToken` fires, then calls `onTurnEnded(undefined)`. onFailureStage('observeTurn'); - return new Promise(resolve => { + const resourceOpens: Promise[] = []; + let resourceOpenError: Error | undefined; + const openResponseResource = async ({ resource, preview }: ISlashCommandResource): Promise => { + if (this._isInvocationAbandoned(cancellationToken)) { + return; + } + const editor = await this._editorService.openEditor({ + resource: this._config.connection.resourceUris.fromAgentHost(resource), + options: { pinned: true, ...(preview ? { override: 'vscode.markdown.preview.editor' } : {}) }, + }); + if (!editor) { + throw new Error(localize('agentHost.openSandboxPolicyFailed', "Could not open the sandbox policy in the editor.")); + } + if (!this._isInvocationAbandoned(cancellationToken)) { + progress([{ kind: 'markdownContent', content: new MarkdownString(localize('agentHost.openedSandboxPolicy', "Opened the sandbox policy in the editor.")) }]); + } + }; + return new Promise((resolve, reject) => { const store = this._createTurnObservation(resolve); const cancelSub = store.add(cancellationToken.onCancellationRequested(() => { cancelSub.dispose(); @@ -3347,11 +3368,28 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC cancellationToken, suppressErrorMarkdown: true, onResponseText, - onTurnEnded: (lastTurn) => { - resolve(lastTurn); - this._turnObservations.deleteAndDispose(store); - this._clientDispatchedTurnIds.delete(turnId); - this._completeSessionTurn(session, request.sessionResource, turnId); + onOpenResponseResource: request.message.trimStart().startsWith('/') + ? resource => { + resourceOpens.push(openResponseResource(resource).catch(error => { + resourceOpenError = error instanceof Error ? error : new Error(String(error)); + this._logService.error('[AgentHost] Failed to open slash command resource', resourceOpenError); + })); + } + : undefined, + onTurnEnded: async (lastTurn) => { + try { + await raceCancellation(Promise.all(resourceOpens), cancellationToken); + if (resourceOpenError) { + throw resourceOpenError; + } + resolve(lastTurn); + } catch (error) { + reject(error); + } finally { + this._turnObservations.deleteAndDispose(store); + this._clientDispatchedTurnIds.delete(turnId); + this._completeSessionTurn(session, request.sessionResource, turnId); + } }, })); }); @@ -3661,6 +3699,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC case ResponsePartKind.SystemNotification: // System notifications don't have an id, so we have to identify it by index if (responseParts$.get().indexOf(initial) >= (opts.initialResponsePartCount ?? 0) && opts.subAgentInvocationId === undefined) { + const resource = readSlashCommandResource(initial); + if (resource && opts.onOpenResponseResource) { + opts.onOpenResponseResource(resource); + break; + } const progress = systemNotificationToChatPart(initial.content, this._config.connectionAuthority, initial._meta); if (progress) { if (progress.kind === 'systemNotification' && progress.presentation === 'workspaceTransition' && progress.accessibilityLabel) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index aeb3b28d9fdd..a74d0f611c43 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -11,6 +11,7 @@ import { escapeMarkdownLinkLabel, escapeMarkdownSyntaxTokens, IMarkdownString, M import { escapeIcons } from '../../../../../../base/common/iconLabels.js'; import { type Tokens } from '../../../../../../base/common/marked/marked.js'; import { rewriteMarkdownLinks as rewriteMarkdownSource } from '../../../../../../base/common/markdownLinks.js'; +import { readSlashCommandResource } from '../../../../../../platform/agentHost/common/meta/agentSlashCommandOutputMeta.js'; import { Mimes } from '../../../../../../base/common/mime.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { posix, win32 } from '../../../../../../base/common/path.js'; @@ -549,6 +550,16 @@ export function systemNotificationToChatPart(content: StringOrMarkdown | undefin if (!content) { return undefined; } + if (readSlashCommandResource({ _meta }) && typeof content !== 'string') { + return { + kind: 'markdownContent', + content: new MarkdownString(rewriteMarkdownSource(content.markdown, { + rewriteLink: token => token.type === 'link' + ? new MarkdownString().appendLink(rewriteAgentHostLinkTarget(token.href, connectionAuthority), token.text).value + : undefined, + })), + }; + } const value = stringOrMarkdownToString(content, connectionAuthority); const markdown = typeof value === 'string' ? new MarkdownString(value) : value; switch (meta.kind) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 21ec26032edc..d9f23010fb21 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -40,6 +40,7 @@ import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, Ag import type { ChatInputRequestWithPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { agentHostAuthority, createAgentHostResourceUriMapper, fromAgentHostUri, identityAgentHostResourceUriMapper, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { withChatInputState } from '../../../../../../platform/agentHost/common/meta/agentHostChatInputState.js'; +import { toSlashCommandResourceMeta } from '../../../../../../platform/agentHost/common/meta/agentSlashCommandOutputMeta.js'; import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; import { VSCODE_EPHEMERAL_SESSION_META_KEY } from '../../../../../../platform/agentHost/common/meta/agentEphemeralSessionMeta.js'; import { getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js'; @@ -93,6 +94,10 @@ import { AgentHostSessionListController } from '../../../browser/agentSessions/a import { AgentHostSessionListStore, type IAgentHostSessionListConnection } from '../../../browser/agentSessions/agentHost/agentHostSessionListStore.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { isResourceEditorInput, ITextDiffEditorPane, IUntypedEditorInput } from '../../../../../common/editor.js'; +import { IResourceEditorInput } from '../../../../../../platform/editor/common/editor.js'; +import { EditorInput } from '../../../../../common/editor/editorInput.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; @@ -1123,6 +1128,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IAgentHostActiveClientService, activeClientService); instantiationService.stub(IAgentHostProtectedResourcesService, { onDidChange: Event.None, getProtectedResources: () => undefined }); instantiationService.stub(IOpenerService, openerService as IOpenerService); + instantiationService.stub(IEditorService, new class extends mock() { }); return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, chatSessionItemControllers, newSessionFolderService, trustController, modelService, workingCopyService, commandService }; } @@ -1925,6 +1931,74 @@ suite('AgentHostChatContribution', () => { }); suite('response resource links', () => { + for (const host of ['local', 'WSL']) { + for (const message of ['/sandbox policy', '/SB policy ', 'Hello']) { + test(`opens the ${host} sandbox policy only for its submitted slash command (${message})`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + const opened: IResourceEditorInput[] = []; + instantiationService.stub(IEditorService, upcastPartial({ + async openEditor(input: EditorInput | IUntypedEditorInput) { + assert.ok(isResourceEditorInput(input)); + opened.push(input); + return upcastPartial({}); + }, + })); + const authority = host === 'local' ? 'local' : agentHostAuthority('vscode-remote://wsl+Ubuntu'); + agentHostService.resourceUris = host === 'local' ? identityAgentHostResourceUriMapper : createAgentHostResourceUriMapper(authority); + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot', agentId: 'agent-host-copilot', sessionType: 'agent-host-copilot', + fullName: 'Test', description: 'test', connection: agentHostService, connectionAuthority: authority, + })); + const { turnPromise, collected, chatSession, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { message }); + const resource = URI.parse('agenthost-content:///reports/policy.md?version=1'); + const content = '[Open Sandbox Policy](agenthost-content:///reports/policy.md?vscodeLinkType=markdown-preview)'; + fire({ + type: ActionType.ChatResponsePart, turnId, + part: { kind: ResponsePartKind.SystemNotification, content: { markdown: content }, _meta: toSlashCommandResourceMeta(resource, true) }, + }); + fire({ type: ActionType.ChatTurnComplete, turnId, duration: 0 }); + await turnPromise; + const fallbackResource = toAgentHostUri(resource.with({ query: '' }), authority); + assert.deepStrictEqual({ + opened: opened.map(input => ({ resource: input.resource?.toString(), options: input.options })), + markdown: collected.flat().filter(part => part.kind === 'markdownContent' || part.kind === 'systemNotification').map(part => part.content.value), + }, message.startsWith('/') ? { + opened: [{ + resource: agentHostService.resourceUris.fromAgentHost(resource).toString(), + options: { pinned: true, override: 'vscode.markdown.preview.editor' }, + }], + markdown: ['Opened the sandbox policy in the editor.'], + } : { + opened: [], + markdown: [`[Open Sandbox Policy](${fallbackResource.with({ query: `${fallbackResource.query}&vscodeLinkType=markdown-preview` }).toString()})`], + }); + chatSession.dispose(); + const restored = await handler.provideChatSessionContent(URI.parse('agent-host-copilot:/new-turntest'), CancellationToken.None); + disposables.add(toDisposable(() => restored.dispose())); + assert.strictEqual(opened.length, message.startsWith('/') ? 1 : 0, 'Restoring history must not reopen the policy'); + })); + } + } + + test('reports failure to open the sandbox policy instead of claiming success', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + instantiationService.stub(IEditorService, upcastPartial({ + async openEditor() { throw new Error('Preview failed'); }, + })); + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot', agentId: 'agent-host-copilot', sessionType: 'agent-host-copilot', + fullName: 'Test', description: 'test', connection: agentHostService, connectionAuthority: 'local', + })); + const { turnPromise, collected, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { message: '/sandbox policy' }); + fire({ + type: ActionType.ChatResponsePart, turnId, + part: { kind: ResponsePartKind.SystemNotification, content: { markdown: '[Open Sandbox Policy](file:///policy.md)' }, _meta: toSlashCommandResourceMeta(URI.file('/policy.md'), true) }, + }); + fire({ type: ActionType.ChatTurnComplete, turnId, duration: 0 }); + await assert.rejects(turnPromise, /Preview failed/); + assert.deepStrictEqual(collected.flat().filter(part => part.kind === 'markdownContent'), []); + })); + test('uses the WSL connection for file links despite a local session authority', () => { const { sessionHandler, agentHostService } = createContribution(disposables); const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 6587886f2e4f..68529db531e9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -51,6 +51,7 @@ import { IFileService } from '../../../../../../platform/files/common/files.js'; import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; import { IAgentHostImportConversationStore } from '../../../browser/agentSessions/agentHost/agentHostImportConversationStore.js'; import { IStorageService, InMemoryStorageService, StorageScope } from '../../../../../../platform/storage/common/storage.js'; @@ -979,6 +980,7 @@ suite('AgentHostClientTools', () => { refreshResolvedConfig: async () => { }, } as Partial as IAgentHostUntitledProvisionalSessionService); instantiationService.stub(ILanguageModelToolsService, toolsService); + instantiationService.stub(IEditorService, new class extends mock() { }); instantiationService.stub(IAgentHostToolSetEnablementService, { observe: () => constObservable({ toolSets: new Map(), tools: new Map() }), getState: () => ({ toolSets: new Map(), tools: new Map() }), diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index b45ee54ff37c..1e87a26f5009 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -813,6 +813,12 @@ export function settingKeyToDisplayFormat(key: string, groupId: string = '', isL case AgentSandboxSettingId.AgentSandboxLspServers: displayLabel = localize('agentSandbox.lspServers.label', "Sandbox LSP Servers"); break; + case AgentSandboxSettingId.AgentSandboxAuthenticateGit: + displayLabel = localize('agentSandbox.authenticateGit.label', "Authenticate git"); + break; + case AgentSandboxSettingId.AgentSandboxAuthenticateGh: + displayLabel = localize('agentSandbox.authenticateGh.label', "Authenticate gh"); + break; case AgentSandboxSettingId.AgentSandboxUserConfiguredPaths: displayLabel = localize('agentSandbox.userConfiguredPaths.label', "User-Configured Paths"); break; diff --git a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts index eeb0a0f6d6f6..54f0e206cf9b 100644 --- a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts +++ b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts @@ -664,6 +664,22 @@ suite('SettingsTree', () => { }); }); + test('settingKeyToDisplayFormat - sandbox credential authentication', () => { + for (const [key, label] of [ + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, 'Authenticate git'], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, 'Authenticate gh'], + ] as const) { + assert.deepStrictEqual( + ['', 'chat.agent.sandbox', 'chat.agent.sandbox.credentials'].map(group => settingKeyToDisplayFormat(key, group)), + [ + { category: 'Chat › Agent › Sandbox › Credentials', label }, + { category: 'Credentials', label }, + { category: '', label }, + ], + ); + } + }); + test('settingKeyToDisplayFormat - sandbox user-configured paths', () => { assert.deepStrictEqual( ['', 'chat.agent.sandbox', 'chat.agent.sandbox.fileSystem'].map(group => diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts index 048b022e6606..965e74835197 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts @@ -17,6 +17,8 @@ export const SANDBOX_SETTING_KEYS: readonly string[] = [ AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, AgentSandboxSettingId.AgentSandboxMcpServers, AgentSandboxSettingId.AgentSandboxLspServers, + AgentSandboxSettingId.AgentSandboxAuthenticateGit, + AgentSandboxSettingId.AgentSandboxAuthenticateGh, AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, AgentSandboxSettingId.AgentSandboxUserConfiguredPaths, AgentSandboxSettingId.AgentSandboxLinuxFileSystem, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 7ac8ed474a06..f02cfabbb7ed 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -9,7 +9,7 @@ import { localize } from '../../../../../nls.js'; import { type IConfigurationPropertySchema } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../platform/sandbox/common/settings.js'; import { SandboxSettingsResolutionHelper } from '../../../../../platform/sandbox/common/sandboxSettingsResolutionHelper.js'; -import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_SANDBOX_LSP_SERVERS_KEY, COPILOT_SANDBOX_MCP_SERVERS_KEY } from '../../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_AUTH_GH_KEY, COPILOT_SANDBOX_AUTH_GIT_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_SANDBOX_LSP_SERVERS_KEY, COPILOT_SANDBOX_MCP_SERVERS_KEY } from '../../../../../platform/policy/common/copilotManagedSettings.js'; import { gitAutoApproveRules } from '../../../../../platform/terminal/common/autoApprove/gitAutoApproveRules.js'; import { powershellAutoApproveRules } from '../../../../../platform/terminal/common/autoApprove/powershellAutoApproveRules.js'; import { sortAutoApproveRules } from '../../../../../platform/terminal/common/autoApprove/sortAutoApproveRules.js'; @@ -644,6 +644,24 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, read(COPILOT_SANDBOX_AUTH_GIT_KEY) !== false), + order: 51, + keywords: ['Sandbox', 'sandboxing'], + markdownDescription: localize('agentSandbox.authenticateGit', "Use your credentials for HTTPS git operations inside the sandbox. Applies only to the Copilot Agent Host harness."), + type: 'boolean', + default: true, + restricted: true, + }, + [AgentSandboxSettingId.AgentSandboxAuthenticateGh]: { + managedSettingsPresentation: read => SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, read(COPILOT_SANDBOX_AUTH_GH_KEY) !== false), + order: 52, + keywords: ['Sandbox', 'sandboxing'], + markdownDescription: localize('agentSandbox.authenticateGh', "Use your GitHub account for gh commands inside the sandbox. Applies only to the Copilot Agent Host harness."), + type: 'boolean', + default: true, + restricted: true, + }, [AgentSandboxSettingId.AgentSandboxAllowDevToolAccess]: { managedSettingsPresentation: read => SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, read(COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY) !== false), order: 60, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts index 42a38a1185f7..76a1670f603c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts @@ -354,6 +354,8 @@ suite('AgentHostSandboxForwarder', () => { for (const [key, hostKey] of [ [AgentSandboxSettingId.AgentSandboxMcpServers, AgentHostSandboxKey.SandboxMcpServers], [AgentSandboxSettingId.AgentSandboxLspServers, AgentHostSandboxKey.SandboxLspServers], + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, AgentHostSandboxKey.AuthenticateGit], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, AgentHostSandboxKey.AuthenticateGh], [AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, AgentHostSandboxKey.AllowDevToolAccess], [AgentSandboxSettingId.AgentSandboxAllowLocalNetwork, AgentHostSandboxKey.AllowLocalNetwork], ]) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts index c8c8cf667ac1..33bb161d28c6 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts @@ -51,6 +51,8 @@ suite('sandboxSettingsReader', () => { [AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, AgentHostSandboxKey.AllowUnsandboxedCommands, true], [AgentSandboxSettingId.AgentSandboxMcpServers, AgentHostSandboxKey.SandboxMcpServers, true], [AgentSandboxSettingId.AgentSandboxLspServers, AgentHostSandboxKey.SandboxLspServers, true], + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, AgentHostSandboxKey.AuthenticateGit, true], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, AgentHostSandboxKey.AuthenticateGh, true], [AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, AgentHostSandboxKey.AllowDevToolAccess, true], [AgentSandboxSettingId.AgentSandboxAllowLocalNetwork, AgentHostSandboxKey.AllowLocalNetwork, false], ] as const) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts index cda70bf4c3c8..46d9222c1b71 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts @@ -10,7 +10,7 @@ import { DefaultConfiguration } from '../../../../../../platform/configuration/c import { AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../../platform/configuration/common/configurationRegistry.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; -import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_ENABLED_KEY } from '../../../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_AUTH_GH_KEY, COPILOT_SANDBOX_AUTH_GIT_KEY, COPILOT_SANDBOX_ENABLED_KEY } from '../../../../../../platform/policy/common/copilotManagedSettings.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; import { WorkspaceConfigurationModelParser } from '../../../../../services/configuration/common/configurationModels.js'; import { sandboxAllowNetworkMigration, terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; @@ -59,6 +59,8 @@ suite('Terminal chat agent tools configuration', () => { [AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, 30], [AgentSandboxSettingId.AgentSandboxMcpServers, 40], [AgentSandboxSettingId.AgentSandboxLspServers, 50], + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, 51], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, 52], [AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, 60], [AgentSandboxSettingId.AgentSandboxUserConfiguredPaths, 65], [AgentSandboxSettingId.AgentSandboxLinuxFileSystem, 70], @@ -86,6 +88,25 @@ suite('Terminal chat agent tools configuration', () => { assert.deepStrictEqual(values, [true, false, true]); }); + test('presents managed credential denial without locking allowed or unmanaged preferences', () => { + const settings = [ + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, COPILOT_SANDBOX_AUTH_GIT_KEY, 'Use your credentials for HTTPS git operations inside the sandbox.'], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, COPILOT_SANDBOX_AUTH_GH_KEY, 'Use your GitHub account for gh commands inside the sandbox.'], + ] as const; + assert.deepStrictEqual(settings.map(([id, managedKey]) => { + const setting = terminalChatAgentToolsConfiguration[id]; + return { + description: setting.markdownDescription, + policy: setting.policy, + presentation: [undefined, false, true].map(value => setting.managedSettingsPresentation?.(key => key === managedKey ? value : undefined)), + }; + }), settings.map(([, , description]) => ({ + description: `${description} Applies only to the Copilot Agent Host harness.`, + policy: undefined, + presentation: [undefined, false, undefined], + }))); + }); + test('registers Copilot user-configured paths and warns about legacy filesystem settings', () => { const setting = terminalChatAgentToolsConfiguration[AgentSandboxSettingId.AgentSandboxUserConfiguredPaths]; assert.deepStrictEqual({ @@ -180,6 +201,8 @@ suite('Terminal chat agent tools configuration', () => { AgentSandboxSettingId.AgentSandboxAllowLocalNetwork, AgentSandboxSettingId.AgentSandboxMcpServers, AgentSandboxSettingId.AgentSandboxLspServers, + AgentSandboxSettingId.AgentSandboxAuthenticateGit, + AgentSandboxSettingId.AgentSandboxAuthenticateGh, AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, AgentSandboxSettingId.AgentSandboxUserConfiguredPaths, ]; @@ -205,6 +228,8 @@ suite('Terminal chat agent tools configuration', () => { for (const [key, defaultValue] of [ [AgentSandboxSettingId.AgentSandboxMcpServers, true], [AgentSandboxSettingId.AgentSandboxLspServers, true], + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, true], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, true], [AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, true], [AgentSandboxSettingId.AgentSandboxAllowLocalNetwork, false], ] as const) { From 3f4682f3ead1fec724e728407167bd96e60aab10 Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:36:29 -0700 Subject: [PATCH 33/49] Keep background shell output visible while it streams (#339636) * Keep background shell output visible while the elapsed time updates The Background Shells picker refreshes each row once a second, and a row's description includes its elapsed time. The dropdown pill's hover cache took the changed description for a different resource and released the row's hover disposable, which closed the live output terminal. The picker then rebuilt it in the open details, so the output vanished and came back on every update. The pill now releases a cached hover disposable only when the entry that replaces it no longer carries it. A removed entry still releases its hover. * Keep a shared hover disposable cached until its entry is removed Addresses review thread https://github.com/microsoft/vscode/pull/339636#discussion_r4180441849. When an entry updated while the dropdown was closed, the pill dropped its cached hover without releasing the disposable the entry still carried, and nothing cached it again until the dropdown reopened. Removing the entry or disposing the pill then no longer released it. The cached hover now stays until the entry is removed or stops carrying that disposable. --- src/vs/workbench/browser/chatDropdownPill.ts | 25 ++++++-- .../sessionBackgroundShellsControl.test.ts | 60 +++++++++++++++++++ .../workbench/test/browser/chatPills.test.ts | 27 +++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/browser/chatDropdownPill.ts b/src/vs/workbench/browser/chatDropdownPill.ts index d48948a6a3be..3f68698c80c7 100644 --- a/src/vs/workbench/browser/chatDropdownPill.ts +++ b/src/vs/workbench/browser/chatDropdownPill.ts @@ -117,8 +117,7 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { for (const [id, cached] of this._resourceDropdownHovers) { const entry = this._entries.find(entry => entry.id === id); if (!entry || !sameResourceHover(cached.entry, entry)) { - cached.hover.disposable?.dispose(); - this._resourceDropdownHovers.delete(id); + this._evictResourceHover(id, entry); } } this._summaryIcon = ThemeIcon.isThemeIcon(this._pillOptions.icon) ? this._pillOptions.icon : this._pillOptions.icon.read(reader); @@ -407,8 +406,7 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { return hover; } if (cached) { - cached.hover.disposable?.dispose(); - this._resourceDropdownHovers.delete(entry.id); + this._evictResourceHover(entry.id, entry); } if (!entry.imagePreview) { if (cacheable && baseHover) { @@ -444,6 +442,25 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { return hover; } + /** + * Releases the hover cached for an entry that changed or was removed. An entry can + * hand out the same hover disposable on every update, for content its source keeps + * alive. That hover stays cached until the entry is removed or stops carrying the + * disposable, so it is still released when the entry updates while the dropdown is closed. + */ + private _evictResourceHover(id: string, replacement: IChatPillEntry | undefined): void { + const cached = this._resourceDropdownHovers.get(id); + if (!cached) { + return; + } + const disposable = cached.hover.disposable; + if (disposable && disposable === replacement?.hover?.disposable) { + return; + } + disposable?.dispose(); + this._resourceDropdownHovers.delete(id); + } + private _prefetchEntry(entry: IChatPillEntry): void { if (!this._prefetchedEntries.has(entry.id)) { this._prefetchedEntries.add(entry.id); diff --git a/src/vs/workbench/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts b/src/vs/workbench/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts index 1d5ef7887e54..f7ac8c36ebf1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts @@ -6,19 +6,27 @@ import assert from 'assert'; import { isHTMLElement } from '../../../../../base/browser/dom.js'; import type { IRenderedMarkdown } from '../../../../../base/browser/markdownRenderer.js'; +import { Action } from '../../../../../base/common/actions.js'; import { timeout } from '../../../../../base/common/async.js'; import { autorun, constObservable, observableValue } from '../../../../../base/common/observable.js'; import type { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../base/test/common/virtualScheduling/index.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; import { IAccessibleViewService } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import type { IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { ChatDropdownPillActionViewItem } from '../../../../browser/chatDropdownPill.js'; import { IDetachedTerminalInstance, IDetachedXtermTerminal, ITerminalService } from '../../../terminal/browser/terminal.js'; import { SessionBackgroundShellsControl, type IChatBackgroundShellsSource } from '../../browser/sessionBackgroundShellsControl.js'; +import { sessionBackgroundShellsPillOptions } from '../../browser/sessionChatPillOptions.js'; import type { ChatBackgroundShellOutput, IChatBackgroundShell } from '../../common/sessionChatPills.js'; suite('SessionBackgroundShellsControl', () => { @@ -213,4 +221,56 @@ suite('SessionBackgroundShellsControl', () => { released: true, }); }); + + test('keeps the live output shown while the picker refreshes elapsed time', () => runWithFakedTimers({}, async () => { + let terminals = 0; + instantiationService.stub(ITerminalService, new class extends mock() { + override createDetachedTerminal(): Promise { + terminals++; + return new Promise(() => { }); + } + }()); + instantiationService.stub(IMarkdownRendererService, new class extends mock() { + override render(): IRenderedMarkdown { return { element: document.createElement('div'), dispose: () => { } }; } + }()); + instantiationService.stub(IAccessibleViewService, new class extends mock() { + override getOpenAriaHint(): string | null { return null; } + }()); + instantiationService.stub(IContextKeyService, new MockContextKeyService()); + let rows: readonly IActionListItem[] = []; + instantiationService.stub(IActionWidgetService, new class extends mock() { + override get isVisible(): boolean { return false; } + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[]): void { rows = items; } + override updateItems(items: readonly IActionListItem[]): void { rows = items; } + override hide(): void { } + }()); + instantiationService.stub(ILanguageService, {}); + instantiationService.stub(IModelService, {}); + instantiationService.stub(IFileService, {}); + const shells = constObservable([{ + id: 'stream', description: 'Stream', command: 'stream.sh', startedAt: new Date(0).toISOString(), + output: constObservable({ status: 'running', text: 'step 1\n' }), + }]); + const control = store.add(instantiationService.createInstance(SessionBackgroundShellsControl, constObservable({ backgroundShells: shells }))); + const pill = store.add(instantiationService.createInstance(ChatDropdownPillActionViewItem, store.add(new Action('shells', 'Background Shells')), {}, control.sections, sessionBackgroundShellsPillOptions)); + const container = document.createElement('div'); + pill.render(container); + container.querySelector('.chat-pill-button')!.click(); + // The picker asks the open row for its details again each time the list updates. + const showDetails = () => { + const content = rows[1].hover?.content; + return typeof content === 'function' ? content().querySelector('.chat-background-shell-output') : null; + }; + const view = showDetails(); + const opened = { badge: rows[1].badge, shown: !!view }; + await timeout(2100); + const refreshed = { badge: rows[1].badge, sameView: showDetails() === view }; + pill.dispose(); + + assert.deepStrictEqual({ opened, refreshed, terminals }, { + opened: { badge: '0ms', shown: true }, + refreshed: { badge: '2s', sameView: true }, + terminals: 1, + }); + })); }); diff --git a/src/vs/workbench/test/browser/chatPills.test.ts b/src/vs/workbench/test/browser/chatPills.test.ts index 7f3a6b367be8..0161d753be20 100644 --- a/src/vs/workbench/test/browser/chatPills.test.ts +++ b/src/vs/workbench/test/browser/chatPills.test.ts @@ -493,6 +493,33 @@ suite('ChatPills', () => { }, { preserved: true, changed: true, copied: ['new'] }); }); + test('keeps hover content an updated entry still owns and releases it when the entry is removed', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + let released = 0; + // The entry's source keeps this content alive across updates, like the live output of a background shell. + const release = { dispose: () => { released++; } }; + const entry = (elapsed: string): IChatPillEntry => ({ + id: 'shell', label: 'Run tests', ariaDescription: elapsed, + hover: { ...getChatPillLocationHover('npm test'), disposable: release }, + open: () => { }, + }); + const sections = observableValue('shells', [{ title: 'Shells', entries: [entry('1s')] }]); + const viewItem = store.add(instantiationService.createInstance(ChatDropdownPillActionViewItem, store.add(new Action('shells', 'Background Shells')), {}, sections, { + widgetId: 'shells', icon: Codicon.terminal, title: 'Background Shells', + summaryLabel: count => `${count} Background Shells`, summaryAriaLabel: count => `Show ${count} background shells`, + singleEntry: ChatPillSingleEntry.Summary, + })); + viewItem.render(mainWindow.document.createElement('div')); + getDropdownPillItems.call(viewItem); + sections.set([{ title: 'Shells', entries: [entry('2s')] }], undefined); + getDropdownPillItems.call(viewItem); + // Nothing rebuilds the dropdown's items after this update, as when the dropdown is closed. + sections.set([{ title: 'Shells', entries: [entry('3s')] }], undefined); + const afterUpdates = released; + sections.set([], undefined); + assert.deepStrictEqual({ afterUpdates, afterRemoval: released }, { afterUpdates: 0, afterRemoval: 1 }); + }); + test('uses the main DOM realm and target auxiliary window', () => { const disposables = store.add(new DisposableStore()); const iframe = mainWindow.document.createElement('iframe'); From 6456d379a868482688de74d918b06ca96f4fa68a Mon Sep 17 00:00:00 2001 From: roblourens Date: Sun, 4 Oct 2026 20:43:01 -0700 Subject: [PATCH 34/49] agentHost: update Copilot SDK to 1.0.17-preview.4 (#339423) * agentHost: update Copilot SDK to 1.0.17-preview.3 Align the bundled runtime pin with 1.0.92-3 and refresh prompt snapshots for updated write_agent steering guidance and deterministic task model ordering. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: align remote Copilot SDK and runtime versions Keep the remote server dependency in sync with the root SDK upgrade as required by the repository hygiene check. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: isolate Copilot OTel batch export scheduling Use a test-owned 100ms native span batch interval so default or inherited scheduling cannot exhaust the bounded export-readiness wait. Preserve the existing span assertions and wait deadline in both telemetry suites. Verified that the previous and upgraded SDK both defer the first default Linux export by about 10 seconds. Reproduced the missing-span failure with a long inherited schedule; the unchanged assertions pass after isolating the child schedule. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: update Copilot SDK to 1.0.17-preview.4 Align root and remote runtime pins with 1.0.92-4, adapt typed sandbox RPC mocks, normalize standalone runtime change notices, and restore warm managed-telemetry regression coverage. Keep the upgrade in draft: live exit-only plan approval continued into edits twice with preview.4, while a preview.3 live control passed. Deterministic replay does not establish that the live behavior is correct. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 72 +++++++++---------- package.json | 4 +- remote/package-lock.json | 72 +++++++++---------- remote/package.json | 4 +- .../agentHost/test/node/copilotAgent.test.ts | 2 +- .../test/node/copilotAgentSession.test.ts | 2 +- .../test/node/copilotSessionLauncher.test.ts | 10 +-- .../agentHost/test/node/e2e/KNOWN_ISSUES.md | 23 ------ .../agentHost/test/node/e2e/README.md | 6 +- .../e2e/harness/modelRequestProjection.ts | 7 +- ...Copilot_prompts_claude-haiku-4_5.prompt.md | 36 +++++----- ..._Copilot_prompts_claude-opus-4_5.prompt.md | 36 +++++----- ..._Copilot_prompts_claude-opus-4_6.prompt.md | 36 +++++----- ..._Copilot_prompts_claude-opus-4_7.prompt.md | 36 +++++----- ...___Copilot_prompts_claude-opus-5.prompt.md | 36 +++++----- ..._Copilot_prompts_claude-opus-5_5.prompt.md | 36 +++++----- ...opilot_prompts_claude-sonnet-4_5.prompt.md | 36 +++++----- ...opilot_prompts_claude-sonnet-4_6.prompt.md | 36 +++++----- ..._Copilot_prompts_claude-sonnet-5.prompt.md | 36 +++++----- ...Copilot_prompts_gemini-2_0-flash.prompt.md | 36 +++++----- ...2E___Copilot_prompts_gpt-5-codex.prompt.md | 36 +++++----- ...E2E___Copilot_prompts_gpt-5-mini.prompt.md | 36 +++++----- ...Host_E2E___Copilot_prompts_gpt-5.prompt.md | 36 +++++----- ...pilot_prompts_gpt-5_1-codex-mini.prompt.md | 36 +++++----- ...___Copilot_prompts_gpt-5_1-codex.prompt.md | 36 +++++----- ...st_E2E___Copilot_prompts_gpt-5_1.prompt.md | 36 +++++----- ...E___Copilot_prompts_gpt-5_6-luna.prompt.md | 36 +++++----- ...2E___Copilot_prompts_gpt-5_6-sol.prompt.md | 36 +++++----- ...___Copilot_prompts_gpt-5_6-terra.prompt.md | 36 +++++----- ...copilotOtelAgentHostE2E.integrationTest.ts | 25 +++---- .../test/node/modelRequestProjection.test.ts | 30 ++++++++ 31 files changed, 473 insertions(+), 468 deletions(-) diff --git a/package-lock.json b/package-lock.json index af47f54f3629..916b20611d05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.0", + "@github/copilot-sdk": "1.0.17-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -1170,9 +1170,9 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.0.tgz", - "integrity": "sha512-XYoM7Jv+KOw28i+SbZ8hddpkSNjFOeBGZX7aGl+dRWzRFGOUlS4wpmaH33I3EvywfXRj48CO/6/0FabX98fk7Q==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.4.tgz", + "integrity": "sha512-tt5pyDLUjiaxJSMCU2HRDLRSFOA4mIFpI6+360EC0jqNDmhlaeJy88YwO6zDO77D21l8lGt8INBj+IYMbPb30Q==", "license": "MIT", "dependencies": { "koffi": "3.2.1", @@ -1183,20 +1183,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-darwin-x64": "1.0.17-preview.0", - "@github/copilot-sdk-linux-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-linux-x64": "1.0.17-preview.0", - "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.0", - "@github/copilot-sdk-win32-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-win32-x64": "1.0.17-preview.0" + "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-darwin-x64": "1.0.17-preview.4", + "@github/copilot-sdk-linux-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-linux-x64": "1.0.17-preview.4", + "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.4", + "@github/copilot-sdk-win32-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-win32-x64": "1.0.17-preview.4" } }, "node_modules/@github/copilot-sdk-darwin-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-p7J5iCeq6ntw4GD42AK7Kmck9jyhVjKZFFH2W8G8B76fopYSBnvMX+LxdfLDkvOHb0OMY91rG0KA9EImZLkiiA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-cufab0CWxFH0wwIw1/dlPNx4gFP/PJoL4+tUQ/d6lZ+SWX4/2VQgRJCQ97qe9Nqql3YWDikiJTTHXQ55xkmmRA==", "cpu": [ "arm64" ], @@ -1207,9 +1207,9 @@ ] }, "node_modules/@github/copilot-sdk-darwin-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-5whrJOGWrfDZz97VH2TavhWPppNbf9E19IcGtF2DhxBCdWklLWhBfSDCMzrWvpiUnukHSJA925sKQCoThJDKZg==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-4kj7nU3oXCFFm24d6FijBkuiLTCBtfiI8qEHsDcSxfgPdPnwV+9osT/EAZRKPTh3DYGwqTO7i+bIHEHgogd+Wg==", "cpu": [ "x64" ], @@ -1220,9 +1220,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-Dm1Q+/SIU4V1/+VBI2zUfTHcrKBAhuMY204WjuVxyry9BBdrAe98XuZuaGv8+ztbWOsIQ/Vhdcbj7PkEBHetrQ==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-aOHcV5YiFGhtxh+H3qhOuMzktyhwuepGl/jWkWu2vTLy2oztpMnxmoRgwJaj9NdNQoiM8PO13YjbFhbb3TGaOA==", "cpu": [ "arm64" ], @@ -1236,9 +1236,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-UG37BcakUmUh5C7Av1ab2smsFJiitev9mWH3vohaAaCYujj/Zb4CRkTVezVA8KLkkdnq67R7AOQSvTFArb0NoA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-W98/SrLJXVk5xn8GgvUxNs8wHM10nPe5Ff3fU9vKb4Kb0aS2KyxoAhC4eoNn8VRB2XDW2wFTBvUJAutCiWGL+Q==", "cpu": [ "x64" ], @@ -1252,9 +1252,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-DgwS8F4IAra48ML6jWprk1HeJAANf7FdjCRIViVAuhaVLvdjbPi/7pYTQGLtwTcxN63O4cG+/1mPKf2roz83YA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-hym02Fv/JuqjmmMqs4E+XFEgx8nzIpIdMLSr5CFpMLWVVKQYBhY8x6r+03qXhfutUWlcoL5f8gK/pdOR23k9jw==", "cpu": [ "arm64" ], @@ -1268,9 +1268,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-9T2ik5qK5akLqVhIyvGVicjcumKGOivvuLaAPYU+210MV+t70u9BGQ0a3Vy3tlgDPGHos/js72ZL2RUo6hmFZg==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-IhjdLXtUSV8lSZiWsciGHD2iwv3nS2pAwLMywfgB1GozouBDIfWZJlbTUX5Jqv4JeZjE7lpvD5e+Sc11HL6kQQ==", "cpu": [ "x64" ], @@ -1284,9 +1284,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-7nSjNxKXQWCEsf+iDQMEW8dr8q4SkQAimemb0U03fqD3jj7JA6Z/XvmAIFb/Cf+RWqTLexHw0O3HjFs9vF5MNQ==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-MxJJiT2DoDXi3a6dPY/DwMbJPweK75ywlIIbK6Agnof055euC4st3bKMUXz8Nuq4pWzXU3HEWFUVWnNr/63TNw==", "cpu": [ "arm64" ], @@ -1297,9 +1297,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-4pa51WL3KZgouFrODCngtmGw3yavDIIEwiK+cGAA7jH45uFu5EpPnuVHlpFIrAU/fa80ZSbiX5KkiigP+EpLow==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-NGC8k/WHw6lP6O6h/nJiw3lBaaaMjb1hu+urr+Wpa9EP9a/PFL4rWXpxixgQT4qym7EAVY+EE1vSTA8jCi7cAA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 80d724d28101..b3ed12cc63d1 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.0", + "@github/copilot-sdk": "1.0.17-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -325,5 +325,5 @@ "@vscode/windows-ca-certs@0.3.4": true, "@vscode/windows-process-tree@0.8.0": true }, - "copilotRuntimeVersion": "1.0.92-0" + "copilotRuntimeVersion": "1.0.92-4" } diff --git a/remote/package-lock.json b/remote/package-lock.json index f0b8fce08d9d..0300e36651d4 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.0", + "@github/copilot-sdk": "1.0.17-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.9.0", @@ -73,9 +73,9 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.0.tgz", - "integrity": "sha512-XYoM7Jv+KOw28i+SbZ8hddpkSNjFOeBGZX7aGl+dRWzRFGOUlS4wpmaH33I3EvywfXRj48CO/6/0FabX98fk7Q==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.4.tgz", + "integrity": "sha512-tt5pyDLUjiaxJSMCU2HRDLRSFOA4mIFpI6+360EC0jqNDmhlaeJy88YwO6zDO77D21l8lGt8INBj+IYMbPb30Q==", "license": "MIT", "dependencies": { "koffi": "3.2.1", @@ -86,20 +86,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-darwin-x64": "1.0.17-preview.0", - "@github/copilot-sdk-linux-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-linux-x64": "1.0.17-preview.0", - "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.0", - "@github/copilot-sdk-win32-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-win32-x64": "1.0.17-preview.0" + "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-darwin-x64": "1.0.17-preview.4", + "@github/copilot-sdk-linux-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-linux-x64": "1.0.17-preview.4", + "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.4", + "@github/copilot-sdk-win32-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-win32-x64": "1.0.17-preview.4" } }, "node_modules/@github/copilot-sdk-darwin-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-p7J5iCeq6ntw4GD42AK7Kmck9jyhVjKZFFH2W8G8B76fopYSBnvMX+LxdfLDkvOHb0OMY91rG0KA9EImZLkiiA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-cufab0CWxFH0wwIw1/dlPNx4gFP/PJoL4+tUQ/d6lZ+SWX4/2VQgRJCQ97qe9Nqql3YWDikiJTTHXQ55xkmmRA==", "cpu": [ "arm64" ], @@ -110,9 +110,9 @@ ] }, "node_modules/@github/copilot-sdk-darwin-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-5whrJOGWrfDZz97VH2TavhWPppNbf9E19IcGtF2DhxBCdWklLWhBfSDCMzrWvpiUnukHSJA925sKQCoThJDKZg==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-4kj7nU3oXCFFm24d6FijBkuiLTCBtfiI8qEHsDcSxfgPdPnwV+9osT/EAZRKPTh3DYGwqTO7i+bIHEHgogd+Wg==", "cpu": [ "x64" ], @@ -123,9 +123,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-Dm1Q+/SIU4V1/+VBI2zUfTHcrKBAhuMY204WjuVxyry9BBdrAe98XuZuaGv8+ztbWOsIQ/Vhdcbj7PkEBHetrQ==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-aOHcV5YiFGhtxh+H3qhOuMzktyhwuepGl/jWkWu2vTLy2oztpMnxmoRgwJaj9NdNQoiM8PO13YjbFhbb3TGaOA==", "cpu": [ "arm64" ], @@ -139,9 +139,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-UG37BcakUmUh5C7Av1ab2smsFJiitev9mWH3vohaAaCYujj/Zb4CRkTVezVA8KLkkdnq67R7AOQSvTFArb0NoA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-W98/SrLJXVk5xn8GgvUxNs8wHM10nPe5Ff3fU9vKb4Kb0aS2KyxoAhC4eoNn8VRB2XDW2wFTBvUJAutCiWGL+Q==", "cpu": [ "x64" ], @@ -155,9 +155,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-DgwS8F4IAra48ML6jWprk1HeJAANf7FdjCRIViVAuhaVLvdjbPi/7pYTQGLtwTcxN63O4cG+/1mPKf2roz83YA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-hym02Fv/JuqjmmMqs4E+XFEgx8nzIpIdMLSr5CFpMLWVVKQYBhY8x6r+03qXhfutUWlcoL5f8gK/pdOR23k9jw==", "cpu": [ "arm64" ], @@ -171,9 +171,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-9T2ik5qK5akLqVhIyvGVicjcumKGOivvuLaAPYU+210MV+t70u9BGQ0a3Vy3tlgDPGHos/js72ZL2RUo6hmFZg==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-IhjdLXtUSV8lSZiWsciGHD2iwv3nS2pAwLMywfgB1GozouBDIfWZJlbTUX5Jqv4JeZjE7lpvD5e+Sc11HL6kQQ==", "cpu": [ "x64" ], @@ -187,9 +187,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-7nSjNxKXQWCEsf+iDQMEW8dr8q4SkQAimemb0U03fqD3jj7JA6Z/XvmAIFb/Cf+RWqTLexHw0O3HjFs9vF5MNQ==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-MxJJiT2DoDXi3a6dPY/DwMbJPweK75ywlIIbK6Agnof055euC4st3bKMUXz8Nuq4pWzXU3HEWFUVWnNr/63TNw==", "cpu": [ "arm64" ], @@ -200,9 +200,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-4pa51WL3KZgouFrODCngtmGw3yavDIIEwiK+cGAA7jH45uFu5EpPnuVHlpFIrAU/fa80ZSbiX5KkiigP+EpLow==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-NGC8k/WHw6lP6O6h/nJiw3lBaaaMjb1hu+urr+Wpa9EP9a/PFL4rWXpxixgQT4qym7EAVY+EE1vSTA8jCi7cAA==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index 12797db32618..9f718de08d78 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.0", + "@github/copilot-sdk": "1.0.17-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.9.0", @@ -78,5 +78,5 @@ "@vscode/windows-ca-certs@0.3.4": true, "@vscode/windows-process-tree@0.8.0": true }, - "copilotRuntimeVersion": "1.0.92-0" + "copilotRuntimeVersion": "1.0.92-4" } diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 2ef9676cfbcd..ddd113d2d306 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1333,7 +1333,7 @@ function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationServic const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client: { - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, createSession: async options => { createOptions = options; reportManagedSettings(options); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index f4773b40fa5f..b6b194552b69 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -1142,7 +1142,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { const launchPlanBase = { client: { - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: options?.getSandboxHostSupport ?? (async () => ({ supported: true, capabilities: [] })) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: options?.getSandboxHostSupport ?? (async () => ({ supported: true, capabilities: [] })) } }, createSession: async () => mockSession as unknown as CopilotSession, resumeSession: async () => mockSession as unknown as CopilotSession, }, diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 4a099798883b..802e3d32be14 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -875,7 +875,7 @@ suite('CopilotSessionLauncher shared session config', () => { resumeConfigs.push(config); return session; }, - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, }; const managedSettingsPermissions: IAgentHostManagedSettingsPermissions = { disableBypassPermissionsMode: 'disable', @@ -1220,7 +1220,7 @@ suite('CopilotSessionLauncher canvas config', () => { return session; }, resumeSession: async () => { throw new Error('Unexpected resume'); }, - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, } as unknown as CopilotClient; const launcher = createTestLauncher(undefined, { [AgentHostCanvasesEnabledConfigKey]: true }); const plan: CopilotSessionLaunchPlan = { @@ -1271,7 +1271,7 @@ suite('CopilotSessionLauncher canvas config', () => { return session; }, resumeSession: async () => { throw new Error('Unexpected resume'); }, - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, } as unknown as CopilotClient; const launcher = createTestLauncher(); const plan: CopilotSessionLaunchPlan = { @@ -1335,7 +1335,7 @@ suite('CopilotSessionLauncher resume fallback', () => { resumeSession: async () => { throw new TestSdkError(message, code); }, - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, }; return { launcher: createTestLauncher(undefined, {}, logService, sessionOpenTelemetry), @@ -2240,7 +2240,7 @@ suite('CopilotSessionLauncher auto tier', () => { }, } as unknown as CopilotSession; const client: CopilotSessionLaunchPlan['client'] = { - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, createSession: async config => { capiCalls.push(config.capi); return session; diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index 8ae447899c1a..ca4f1788838c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -55,29 +55,6 @@ The session's combined changes sometimes omit edits from one of its two chats, f scripts\test-integration.bat --run src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts --grep "session changeset aggregates provider edits from default and peer chats" ``` -### Copilot managed telemetry changes require a host restart - -An administrator can change telemetry policy while a user has an Agent Host running. -After the runtime has exported a turn under the original policy, creating another -session with a changed telemetry service name fails instead of starting the chat. -Restarting the host lets the same new policy work, including message-content capture. - -- Test: `new sessions honor changed managed telemetry without restarting` in `providers/copilotOtelAgentHostE2E.integrationTest.ts`. -- Scope: Copilot, record and replay. Reproduced locally on macOS with SDK `1.0.16` / runtime `1.0.90`; other platforms remain unvalidated. -- Expected: the second session completes without a manual restart, and its decoded inference span contains its actual user message under service B, correlated using the provider session ID reported over AHP. -- Observed: the second turn fails with `Managed telemetry conflicts with the already selected OTel configuration`. The runtime deliberately permits only one effective telemetry configuration per process; suppressing the error or retaining policy A is not a fix. -- Controls: unchanged-policy sessions and an explicit host restart both pass with capture enabled. Runtime `1.0.89-3` from SDK `1.0.15-preview.3`, tested through the current host's runtime-path override, completes the first turn but exports no inference spans. It is not a green baseline for the full telemetry contract. -- Expected failure: runs by default and accepts only the second session's known configuration-conflict error, tracked by [github/copilot-agent-runtime#24069](https://github.com/github/copilot-agent-runtime/pull/24069). A successful second turn and export fail the test as an unexpected pass, requiring removal of the marker. Setup, replay, and other failures remain failures. -- After that recognized failure only, teardown permits unused future model responses; all observed requests must still match the recording. -- Fixture provenance: the two trivial model responses were generated with an explicit restart between turns, then the restart was removed and the warm failure was confirmed in strict replay. The permanent warm scenario must never restart the host. -- Reproduce: - - ```bash - ./scripts/test-integration.sh --run \ - src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts \ - --grep "new sessions honor changed managed telemetry without restarting" - ``` - ### Binary writes to client-hosted files are corrupted An agent host can address files that live on a connected client and send symmetric AHP filesystem operations back to that client. When the host writes binary content this way, bytes that are not valid UTF-8 are replaced before they reach the client, so images and other binary files can be corrupted. diff --git a/src/vs/platform/agentHost/test/node/e2e/README.md b/src/vs/platform/agentHost/test/node/e2e/README.md index d1ecc0f9db76..184dec9ddea9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/README.md +++ b/src/vs/platform/agentHost/test/node/e2e/README.md @@ -118,7 +118,7 @@ The residual case is `providerHostOnlyTest(...)`: per-provider, but no model tra | `captures/*.yaml` | Committed model fixtures, plus one shared strict empty fixture for tests that declare no model traffic. | | `conformance/__snapshots__/`, `providers/__snapshots__/` | Semantic AHP snapshots (`*.traffic.ahp.yaml`) and assembled-prompt snapshots (`*.prompt.md`), resolved relative to the entry point that registered the test. | | `providers/copilotPromptsE2E.integrationTest.ts` | The provider request-body boundary: the complete model request body the bundled Copilot CLI sends, read off a replayed turn. See [Prompt snapshots](#prompt-snapshots). | -| `providers/copilotOtelAgentHostE2E.integrationTest.ts` | Native Copilot telemetry: Agent Host file export, managed content capture across sessions, and policy changes after a host restart. The warm policy-change gap is tracked in `KNOWN_ISSUES.md`. | +| `providers/copilotOtelAgentHostE2E.integrationTest.ts` | Native Copilot telemetry: Agent Host file export, managed content capture across sessions, and policy changes with and without a host restart. | | `coverage/summary.json` | Checked-in line coverage of the host implementation. | | `coverage/protocol-surface.json` | Checked-in coverage of the AHP contract itself. | | [`KNOWN_ISSUES.md`](./KNOWN_ISSUES.md) | Inventory and reevaluation process for disabled or conditional tests. | @@ -212,6 +212,8 @@ Each elision has a reason, and dropping any of them would make the assertion eit A single text block left after removing reasoning is compared as bare text, matching the replay codec's representation. Multiple text blocks and mixed text/tool content retain their structure. +Runtime-authored change notices are elided from user text. A standalone user message containing only those recognized notices is omitted as well; user questions, empty authored messages, assistant messages, and tool-result wiring remain asserted. + A mismatch fails the test as `[capi-replay] N model request mismatch(es)` and prints both projections. It usually means the capture is stale — the prompt or the host's prompt assembly changed without a re-record — so **re-record it** (see [Updating snapshots and fixtures](#updating-snapshots-and-fixtures)). Never hand-edit the request block to match. If a capture genuinely cannot be refreshed, add its test title to `STALE_RECORDED_REQUEST_EXCEPTIONS` in `agentHostE2ETestHarness.ts` with a `KNOWN_ISSUES.md` entry. --- @@ -285,7 +287,7 @@ On Windows, test-server cleanup records descendants before requesting graceful s The complete-suite runner parallelizes above this lease: conformance, Claude, Codex, Copilot, and Copilot OTel each run in an isolated test process with their own server lease. Tests within one entrypoint stay serial and continue sharing servers, preserving the lifecycle and fixture-window invariants while letting the independent entrypoints overlap. Managed-telemetry tests use a fresh lease and loopback collector per test because their policy and exporter configuration are process-scoped. -Copilot OTel leases clear inherited generic and trace-specific OTLP certificate/key variables in the child environment because both managed telemetry and the Agent Host file-export path use plain HTTP loopback collectors. The parent process environment is unchanged. +Copilot OTel leases clear inherited generic and trace-specific OTLP certificate/key variables in the child environment because both managed telemetry and the Agent Host file-export path use plain HTTP loopback collectors. They also set `OTEL_BSP_SCHEDULE_DELAY=100` so native span batching does not consume the bounded export-readiness wait or inherit a developer's longer export schedule. The tests still wait for and assert the actual exported spans; the parent process environment is unchanged. The file-export test also sets `OTEL_BSP_SCHEDULE_DELAY=100` in its child environment so native SDK batching does not race the ten-second span-polling budget. It still waits for the actual SDK and host spans in the exported file; neither the polling deadline nor the required spans are relaxed. diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts b/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts index 4e6f69ac7ac1..13eb10b4624b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts @@ -182,7 +182,12 @@ function projectContent(content: unknown): unknown { export function projectModelRequest(request: IReadableAnthropicRequest): IProjectedModelRequest { return { system: request.system, - messages: request.messages.map(message => ({ role: message.role, content: projectContent(message.content) })), + messages: request.messages + .filter(message => !(message.role === 'user' + && typeof message.content === 'string' + && message.content.length > 0 + && elideChangeNotices(message.content) === '')) + .map(message => ({ role: message.role, content: projectContent(message.content) })), }; } diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md index 404e3521c26e..51b968e02e1d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md index 76a42ecad71c..fe0ab29e3058 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md index 0fa2c17aeff0..035a60333311 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md index 6ebe420b9983..9b9e665afe27 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md index 3694fe3a98cb..4ee567f27b25 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md index 6c5e3bcd56bd..6c248ea61eaf 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md index 78a03e17aacf..7cbe0ffee276 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md index 52f08a3851d5..94ef86834855 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md index c0db54245d19..b7577fc290b4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md index 639eda1c1ba2..145da0c7adfb 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md @@ -353,7 +353,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -379,7 +379,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -531,28 +531,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md index 458bf717ee4a..9f6b57d183ee 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md @@ -314,7 +314,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -340,7 +340,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -492,28 +492,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md index 2592a3e4f8e5..395c89013ca5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md @@ -353,7 +353,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -379,7 +379,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -531,28 +531,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md index 93ce8d3c9c52..e08934557764 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md @@ -353,7 +353,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -379,7 +379,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -531,28 +531,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md index 15a3f6a5ac8d..633d147af14e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md @@ -314,7 +314,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -340,7 +340,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -492,28 +492,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md index 94eca0759fa4..7692ed331783 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md @@ -314,7 +314,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -340,7 +340,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -492,28 +492,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md index 697b694f55ac..0c0190cf34cd 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md @@ -353,7 +353,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -379,7 +379,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -531,28 +531,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md index bc3267784b27..40d489e135fd 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md @@ -333,7 +333,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -359,7 +359,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -511,28 +511,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md index 47387dd0c55f..59bde33a7472 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md @@ -333,7 +333,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -359,7 +359,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -511,28 +511,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md index 9e56a932f11c..d432d581ca2e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md @@ -333,7 +333,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -359,7 +359,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -511,28 +511,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", - "claude-opus-4.7", - "claude-sonnet-4.6", "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts index 633a5d635c5e..64704c9f3809 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts @@ -21,12 +21,11 @@ import { AgentHostE2EServerLease, createRealSession, driveTurnToCompletion, remo import { fetchSessionWithChat, TestProtocolClient } from '../../serverIntegrationTestHelpers.js'; import type { CapiReplayProxy } from '../harness/capiReplayProxy.js'; import { normalizeVolatileText } from '../harness/capiWireCodec.js'; -import { assertExpectedFailure } from '../harness/expectedFailure.js'; import { COPILOT_CONFIG } from './copilotTestConfiguration.js'; -const RECORD = process.env['AGENT_HOST_REPLAY_RECORD'] === '1' || process.env['AGENT_HOST_UPDATE_SNAPSHOTS'] === '1'; - -const clearedOtlpTlsEnv = { +const otelTestEnv = { + // Keep native batch export within the bounded wait, independent of inherited or default scheduling. + OTEL_BSP_SCHEDULE_DELAY: '100', OTEL_EXPORTER_OTLP_CERTIFICATE: '', OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: '', OTEL_EXPORTER_OTLP_CLIENT_KEY: '', @@ -48,12 +47,11 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { let workspace: string; let collector: ILocalOtlpHttpReceiver; let initialized: boolean; - let allowUnconsumedResponses: boolean; teardown(async function () { this.timeout(120_000); try { - await lease?.release(createdSessions, this.currentTest?.state === 'failed', { allowUnconsumedResponses }); + await lease?.release(createdSessions, this.currentTest?.state === 'failed'); } finally { try { await lease?.dispose(); @@ -71,7 +69,6 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { spans.length = 0; decodeErrors.length = 0; initialized = false; - allowUnconsumedResponses = false; const directory = await mkdtemp(join(tmpdir(), 'copilot-managed-otel-')); tempDirs.push(directory); workspace = join(directory, 'workspace'); @@ -88,7 +85,7 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { endpoint = collector.baseUrl; lease = new AgentHostE2EServerLease(COPILOT_CONFIG, { env: { - ...clearedOtlpTlsEnv, + ...otelTestEnv, COPILOT_CACHE_HOME: cacheHome, COPILOT_MANAGED_SETTINGS_CACHE: 'true', COPILOT_OTEL_ENABLED: 'false', @@ -177,14 +174,10 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { await completeCapturedTurn('otel-policy-a', 'otel-capture-second', await setPolicy('otel-policy-a')); }); - (RECORD ? test.skip : test)('new sessions honor changed managed telemetry without restarting', async function () { + test('new sessions honor changed managed telemetry without restarting', async function () { this.timeout(180_000); await completeCapturedTurn('otel-policy-a', 'otel-policy-first', await setPolicy('otel-policy-a')); - await assertExpectedFailure('github/copilot-agent-runtime#24069', - /^Session error while driving otel-policy-second: sendFailed: Request session\.create failed with message: Standalone managed telemetry preparation failed: Managed telemetry conflicts with the already selected OTel configuration$/, async () => { - await completeCapturedTurn('otel-policy-b', 'otel-policy-second', await setPolicy('otel-policy-b')); - }); - allowUnconsumedResponses = true; + await completeCapturedTurn('otel-policy-b', 'otel-policy-second', await setPolicy('otel-policy-b')); }); test('new sessions honor changed managed telemetry after restarting', async function () { @@ -211,14 +204,12 @@ suite('Agent Host E2E — Copilot OTel file exporter', function () { exportFile = join(directory, 'spans.jsonl'); lease = new AgentHostE2EServerLease(COPILOT_CONFIG, { env: { - ...clearedOtlpTlsEnv, + ...otelTestEnv, COPILOT_OTEL_ENABLED: 'true', COPILOT_OTEL_DB_SPAN_EXPORTER_ENABLED: 'true', COPILOT_OTEL_EXPORTER_TYPE: 'file', COPILOT_OTEL_FILE_EXPORTER_PATH: exportFile, OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: 'true', - // Keep SDK batching comfortably inside the file-export polling budget. - OTEL_BSP_SCHEDULE_DELAY: '100', }, }); }); diff --git a/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts b/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts index 16dc525be2f5..efbfa64d1c2f 100644 --- a/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts +++ b/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts @@ -186,6 +186,36 @@ suite('modelRequestProjection', () => { ), false); }); + test('standalone runtime change notices do not change the retained conversation', () => { + const notice = '\nPlan mode is no longer active.\n'; + const messages = [ + { role: 'user', content: 'Write a plan.' }, + { role: 'assistant', content: 'Plan approved.' }, + { role: 'user', content: 'What did the plan say?' }, + ]; + assert.deepStrictEqual(projectModelRequest(request([ + messages[0], + { role: 'user', content: notice }, + messages[1], + messages[2], + ])), projectModelRequest(request(messages))); + }); + + test('notice elision preserves empty user input, assistant messages, and tool results', () => { + const notice = '\nPlan mode is no longer active.\n'; + assert.deepStrictEqual(projectModelRequest(request([ + { role: 'user', content: '' }, + { role: 'assistant', content: notice }, + { role: 'user', content: `${notice}\nKeep this question.` }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolcall_0', content: notice }] }, + ])).messages, [ + { role: 'user', content: '' }, + { role: 'assistant', content: '' }, + { role: 'user', content: 'Keep this question.' }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolcall_0', content: TOOL_RESULT_PLACEHOLDER }] }, + ]); + }); + test('a tool input matches regardless of key order', () => { // The `input` is JSON the model produced; its key order is not // guaranteed to survive a re-record or a YAML round-trip, and comparing From 40bb6fae207507042de8b6d7b74e9aa374a212a4 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sun, 4 Oct 2026 20:53:31 -0700 Subject: [PATCH 35/49] sessions: configure cloud sandbox models and permissions (#339626) * sessions: configure cloud sandbox models and permissions Discover cloud models and reasoning options through Mission Control, reuse mode and approval controls for sandbox drafts, and retain selections through the first turn and restoration. Keep service-discovered models available when the sandbox AHP catalog omits them. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: update cloud sandbox accessibility help expectations Assert mode, permissions, model, and reasoning guidance instead of the removed Agent Default placeholder. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address cloud sandbox picker review feedback Preserve Allow All defaults, restore toolbar keyboard focus, and share account model discovery and retry notifications across sandbox providers. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/cloudSandboxAgentHost.ts | 9 + .../common/agentHostSessionsProvider.ts | 6 +- .../browser/sessionsChatAccessibilityHelp.ts | 2 +- .../sessionsChatAccessibilityHelp.test.ts | 6 +- .../agentHost/browser/agentHostModePicker.ts | 24 +-- .../agentHostPermissionPickerDelegate.ts | 26 +-- .../browser/baseAgentHostSessionsProvider.ts | 11 +- .../test/browser/agentHostModePicker.test.ts | 28 ++- .../localAgentHostSessionsProvider.test.ts | 32 ++++ .../COPILOT_CHAT_SESSIONS_PROVIDER.md | 2 +- .../browser/cloudSandboxSessionConfig.ts | 48 +++++ .../browser/copilotChatSessionsActions.ts | 64 ++++--- .../browser/copilotChatSessionsProvider.ts | 166 +++++++++++++++--- .../browser/permissionPicker.ts | 10 ++ .../copilotChatSessionsProvider.test.ts | 145 ++++++++++++++- .../test/browser/permissionPicker.test.ts | 29 +++ .../browser/cloudSandboxSessionsProvider.ts | 22 ++- .../remoteAgentHostSessionsProvider.test.ts | 31 ++++ .../sessions/common/sessionsProvider.ts | 14 ++ .../agentHostLanguageModelProvider.ts | 10 +- .../remoteAgentHost/cloudSandboxApiService.ts | 66 +++++++ .../remoteAgentHost/cloudSandboxModels.ts | 161 +++++++++++++++++ .../remoteAgentHost/cloudSandboxTelemetry.ts | 4 +- .../remoteAgentHost.contribution.ts | 2 + .../remoteAgentHostChatContribution.ts | 7 + .../agentHostLanguageModelProvider.test.ts | 25 ++- .../cloudSandboxApiService.test.ts | 44 +++++ .../cloudSandboxModels.test.ts | 163 +++++++++++++++++ 28 files changed, 1059 insertions(+), 98 deletions(-) create mode 100644 src/vs/sessions/contrib/providers/copilotChatSessions/browser/cloudSandboxSessionConfig.ts create mode 100644 src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxModels.test.ts diff --git a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts index def6d36c19c2..de0677e3b9db 100644 --- a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts +++ b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts @@ -17,6 +17,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import { RemoteAgentHostsEnabledSettingId } from './remoteAgentHostService.js'; import { IReplayedTaskHistory } from './taskEventReplay.js'; import { SessionStatus } from './state/sessionState.js'; +import { SessionModelInfo } from './state/protocol/state.js'; /** Configuration key gating the cloud-sandbox connection path. Disabled by default. */ export const CloudSandboxEnabledSettingId = 'chat.agentHost.cloudSandbox.enabled'; @@ -235,6 +236,11 @@ export interface ICloudSandboxConnectionRequest { export const ICloudSandboxApiService = createDecorator('cloudSandboxApiService'); +export interface ICloudSandboxModelCatalog { + readonly models: readonly SessionModelInfo[]; + readonly defaultModel?: string; +} + /** Account identity and control-plane APIs for sandbox credentials, discovery, and persisted history. */ export interface ICloudSandboxApiService { readonly _serviceBrand: undefined; @@ -245,6 +251,9 @@ export interface ICloudSandboxApiService { /** Resolves an opaque, credential-free account key using the same identity as task requests. */ getAccountKey(): Promise; + /** Account-authorized cloud model catalog, available before a sandbox is provisioned. */ + listModels(token: CancellationToken): Promise; + /** * Mint a fresh client Web PubSub connection token for a new logical connection. May resolve to a * "waking" result the caller should retry. diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 52517c83f7ec..ab94bff7230d 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -15,7 +15,7 @@ import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platfo import { AgentCustomization, Customization, McpServerStatus, RootConfigState, type CustomizationEnablement, type McpServerState, type RootState, type TextRange } from '../../platform/agentHost/common/state/protocol/state.js'; import { type CustomizationDisabledReason } from '../../platform/agentHost/common/customizationEnablement.js'; import { type McpServerSource } from '../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; -import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; +import { ISessionConfigProvider, ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; import { ISessionAgentRef } from '../services/sessions/common/session.js'; import type { AgentMergeSessionOverrides, AgentMergeSessionState } from '../../platform/agentHost/common/agentMerge.js'; import type { ISessionSandboxPolicy } from '../../platform/agentHost/common/meta/agentSandboxPolicyMeta.js'; @@ -391,6 +391,10 @@ export function isAgentHostProvider(provider: ISessionsProvider): provider is IA return isAgentHostProviderId(provider.id); } +export function getSessionConfigProvider(provider: ISessionsProvider): ISessionConfigProvider | undefined { + return provider.sessionConfig ?? (isAgentHostProvider(provider) ? provider : undefined); +} + /** * Checks whether a provider ID is for an agent host provider * (`local-agent-host` or any `agenthost-*` provider). diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 8587b98fdd02..b8b19ef20572 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -144,7 +144,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.repositorySelection', "When choosing a GitHub repository in the browser, search or enter a GitHub URL or owner/repository. Use the arrow keys to navigate results, Enter to select, and Escape to cancel. Sign in to GitHub if prompted.")); } if (accessor.get(IAgentHostFilterService).selectedHost?.sessionCreationProviderId) { - content.push(localize('sessionsChat.repositoryCreation', "This host creates a new environment for each session. Open Select Repository to choose its repository. Selecting a repository does not start an environment; sending your first message does. The harness label identifies the available agent, and Agent Default means the host chooses the model.")); + content.push(localize('sessionsChat.repositoryCreation', "This host creates a new environment for each session. Open Select Repository to choose its repository. Selecting a repository does not start an environment; sending your first message does. Before sending, use the mode picker to choose Interactive, Plan, or Autopilot, and the permissions picker to configure tool approvals. Open the model picker to choose a cloud model and its supported reasoning effort. Use the arrow keys to navigate options, Enter to select, and Escape to close a picker.")); } content.push(localize('sessionsChat.workspaceHandoff', "Opening Agents from an editor can suggest that editor's folder for a fresh session. Choosing a workspace yourself or starting another session cancels a pending suggestion. If an explicitly requested workspace cannot be selected, a notification offers Retry and Choose Workspace actions.")); content.push(localize('sessionsChat.branchPickerUpstream', "For a new Agent Host session in a Git repository, use Tab to reach the branch picker and press Enter or Space to choose a base branch. With New Worktree selected, the current branch's tracked upstream is selected by default when available; otherwise the default branch is selected. The picker lists the current branch's upstream and the current branch when it is not the default branch, then the default branch's remote-tracking branch and the default branch, then the other branches, with a separator between each group, and focuses the selected branch when it opens. Each branch is announced with its group name: Current Branch, Default Branch, or Branches. With Branch selected, the checked-out branch, which is the selected branch, is listed first when it is not the default branch, followed by the default branch in its own group, then the other branches. Use the arrow keys to choose a branch and press Enter to select it.")); 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 1005db7644cb..32f4ebbfef19 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts @@ -348,12 +348,14 @@ suite('SessionsChatAccessibilityHelp', () => { browserRepositorySearch: content.includes('When choosing a GitHub repository in the browser, search or enter a GitHub URL or owner/repository.'), repositoryCreation: content.includes('Open Select Repository to choose its repository.'), firstSend: content.includes('Selecting a repository does not start an environment; sending your first message does.'), - defaultModel: content.includes('Agent Default means the host chooses the model.'), + modeAndPermissions: content.includes('Before sending, use the mode picker to choose Interactive, Plan, or Autopilot, and the permissions picker to configure tool approvals.'), + modelAndReasoning: content.includes('Open the model picker to choose a cloud model and its supported reasoning effort.'), }, { browserRepositorySearch: isWeb, repositoryCreation: sessionCreationProviderId !== undefined, firstSend: sessionCreationProviderId !== undefined, - defaultModel: sessionCreationProviderId !== undefined, + modeAndPermissions: sessionCreationProviderId !== undefined, + modelAndReasoning: sessionCreationProviderId !== undefined, }); }); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts index 063dc6883696..23a862a94726 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts @@ -13,6 +13,7 @@ import { autorun, IObservable } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { hasKey } from '../../../../../base/common/types.js'; import { localize } from '../../../../../nls.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; @@ -22,10 +23,10 @@ import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessi import { isSessionConfigWritable } from '../../../../../platform/agentHost/common/sessionConfigProperties.js'; import { SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; +import { getSessionConfigProvider } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; -import { type ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { type ISessionConfigProvider, type ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { reportNewChatPickerClosed } from '../../../chat/browser/newChatPickerTelemetry.js'; import { ChatPetAchievementIds, didExplicitlyEnableChatPetAutopilot } from '../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; import { IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; @@ -130,10 +131,11 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { private _watchProviders(providers: readonly ISessionsProvider[]): void { for (const provider of providers) { - if (!isAgentHostProvider(provider) || this._providerListeners.has(provider.id)) { + const configProvider = getSessionConfigProvider(provider); + if (!configProvider || this._providerListeners.has(provider.id)) { continue; } - this._providerListeners.set(provider.id, provider.onDidChangeSessionConfig(() => this._updateTrigger())); + this._providerListeners.set(provider.id, configProvider.onDidChangeSessionConfig(() => this._updateTrigger())); } } @@ -182,23 +184,25 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { return false; } const provider = this._sessionsProvidersService.getProvider(session.providerId); - if (!provider || !isAgentHostProvider(provider)) { + const configProvider = provider && getSessionConfigProvider(provider); + if (!configProvider) { return false; } - return provider.isSessionConfigResolving(session.sessionId).get(); + return configProvider.isSessionConfigResolving(session.sessionId).get(); } showPicker(anchor: HTMLElement, onHide?: () => void): boolean { return this._showPicker(anchor, onHide); } - protected _getActiveContext(): { provider: IAgentHostSessionsProvider; sessionId: string; currentValue: string; items: readonly IAgentHostSessionEnumPickerItem[]; tooltip: string } | undefined { + protected _getActiveContext(): { provider: ISessionConfigProvider; sessionId: string; currentValue: string; items: readonly IAgentHostSessionEnumPickerItem[]; tooltip: string } | undefined { const session = this._session.get(); if (!session) { return undefined; } - const rawProvider = this._sessionsProvidersService.getProvider(session.providerId); - if (!rawProvider || !isAgentHostProvider(rawProvider)) { + const provider = this._sessionsProvidersService.getProvider(session.providerId); + const rawProvider = provider && getSessionConfigProvider(provider); + if (!rawProvider) { return undefined; } const config = rawProvider.getSessionConfig(session.sessionId); @@ -312,7 +316,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { }); ctx.provider.setSessionConfigValue(ctx.sessionId, this._property, item.value) .then(() => this._onDidSelectValue(ctx.currentValue, item.value)) - .catch(() => { /* best-effort */ }); + .catch(onUnexpectedError); }, onHide: () => { this._pickerVisible = false; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts index 89b301be62d9..05393ea48686 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts @@ -19,8 +19,8 @@ import { narrowCodexPermissionsPreset } from '../../../../../platform/agentHost/ import { SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatConfiguration, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { IPermissionLevelMeta, IPermissionPickerDelegate } from '../../copilotChatSessions/browser/permissionPicker.js'; -import { IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; -import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { getSessionConfigProvider, IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; +import { ISessionConfigProvider, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -115,7 +115,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe const isDevContainer = derived(this, reader => { this._configChangedSignal.read(reader); const session = this._session.read(reader); - return !!session && this._getProvider(session.providerId)?.isDevContainerRequested?.(session.sessionId) === true; + return !!session && this._getAgentHostProvider(session.providerId)?.isDevContainerRequested?.(session.sessionId) === true; }); const sandboxPolicy = derived(this, reader => { if (isDevContainer.read(reader)) { @@ -124,7 +124,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe } this._configChangedSignal.read(reader); const session = this._session.read(reader); - const policy = session && this._getProvider(session.providerId)?.getSessionSandboxPolicy?.(session.sessionId); + const policy = session && this._getAgentHostProvider(session.providerId)?.getSessionSandboxPolicy?.(session.sessionId); if (policy || isWeb || environmentService.remoteAuthority || session?.providerId !== LOCAL_AGENT_HOST_PROVIDER_ID || session.sessionType !== CopilotCLISessionType.id) { return policy; } @@ -161,7 +161,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe } this._configChangedSignal.read(reader); const session = this._session.read(reader); - return session && this._getProvider(session.providerId)?.getSessionSandboxEnabled?.(session.sessionId); + return session && this._getAgentHostProvider(session.providerId)?.getSessionSandboxEnabled?.(session.sessionId); }); this.sandboxEnabled = derived(this, reader => { this._configChangedSignal.read(reader); @@ -228,7 +228,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe throw new Error('Sandbox configuration is unavailable for this session'); } const operation = provider.setSessionConfigValue(session.sessionId, SessionConfigKey.SandboxEnabled, enabled ? 'on' : 'off'); - provider.trackSessionConfigOperation(session.sessionId, operation); + provider.trackSessionConfigOperation?.(session.sessionId, operation); void operation.catch(onUnexpectedError); } @@ -256,7 +256,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe throw new Error('Approval configuration is unavailable for this session'); } const operation = provider.setSessionConfigValue(session.sessionId, approvalProperty.key, value); - provider.trackSessionConfigOperation(session.sessionId, operation); + provider.trackSessionConfigOperation?.(session.sessionId, operation); await operation.catch(onUnexpectedError); } @@ -313,18 +313,24 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe return !!approvalProperty && isSessionConfigWritable(approvalProperty.schema, provider.getCreateSessionConfig(session.sessionId) !== undefined); } - private _getProvider(providerId: string): IAgentHostSessionsProvider | undefined { + private _getProvider(providerId: string): ISessionConfigProvider | undefined { + const provider = this._sessionsProvidersService.getProvider(providerId); + return provider && getSessionConfigProvider(provider); + } + + private _getAgentHostProvider(providerId: string): IAgentHostSessionsProvider | undefined { const provider = this._sessionsProvidersService.getProvider(providerId); return provider && isAgentHostProvider(provider) ? provider : undefined; } private _watchProviders(providers: readonly ISessionsProvider[]): void { for (const provider of providers) { - if (!isAgentHostProvider(provider) || this._providerSubscriptions.has(provider.id)) { + const configProvider = getSessionConfigProvider(provider); + if (!configProvider || this._providerSubscriptions.has(provider.id)) { continue; } const subscriptions = new DisposableStore(); - subscriptions.add(provider.onDidChangeSessionConfig(() => { + subscriptions.add(configProvider.onDidChangeSessionConfig(() => { this._configChangedSignal.trigger(undefined); })); this._providerSubscriptions.set(provider.id, subscriptions); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index c4c81afa4f8b..c10bfdcfea01 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -5130,7 +5130,15 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // -- Dynamic session config ---------------------------------------------- getAutomationModelConfiguration(sessionId: string): AutomationModelConfiguration | undefined { - return this._getNewSession(sessionId)?.modelConfiguration ?? this._runningModelConfigurations.get(sessionId); + const draft = this._getNewSession(sessionId); + if (draft) { + return draft.modelConfiguration; + } + const rawId = this._rawIdFromChatId(sessionId); + if (!this._runningModelConfigurations.has(sessionId) && rawId && this._sessionCache.has(rawId)) { + this._runningModelConfigurations.set(sessionId, new AutomationModelConfiguration(this._languageModelsService)); + } + return this._runningModelConfigurations.get(sessionId); } async getAutomationSessionConfiguration(sessionId: string): Promise { @@ -6668,6 +6676,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement location: ChatAgentLocation.Chat, userSelectedModelId: selectedModelId, userSelectedModelConfiguration: selectedModelConfiguration, + agentHostSessionConfig: options.sessionConfig, modeInfo: selectedAgentUri ? { kind: ChatModeKind.Agent, isBuiltin: false, diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.test.ts index a39459ce10b7..96888975095f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.test.ts @@ -36,7 +36,7 @@ import { NullTelemetryService } from '../../../../../../platform/telemetry/commo import { IChatPetService } from '../../../../../../workbench/contrib/chat/browser/chatPetService.js'; import { IChatPhoneInputPresenter } from '../../../../../../workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.js'; import { resetShownWarnings } from '../../../../../../workbench/contrib/chat/common/chatPermissionWarnings.js'; -import { ChatConfiguration } from '../../../../../../workbench/contrib/chat/common/constants.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { TestStorageService } from '../../../../../../workbench/test/common/workbenchTestServices.js'; import { IWorkbenchEnvironmentService } from '../../../../../../workbench/services/environment/common/environmentService.js'; import { IOpenSettingsOptions, IPreferencesService } from '../../../../../../workbench/services/preferences/common/preferences.js'; @@ -44,7 +44,7 @@ import { AGENT_HOST_PERMISSIONS_SETTINGS_QUERY, MODE_PERMISSIONS_PICKER_OPEN_ATT import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; -import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; +import { ISessionConfigProvider, ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; import { AgentHostModePicker } from '../../browser/agentHostModePicker.js'; import { AgentHostPermissionPickerDelegate } from '../../browser/agentHostPermissionPickerDelegate.js'; import { PickerActionViewItem } from '../../browser/agentHostSessionConfigPicker.js'; @@ -56,7 +56,7 @@ suite('AgentHostModePicker', () => { teardown(() => resetShownWarnings()); - function setup(enabled = true, confirmPermissions = true, policyRestricted = false, publishSandboxPolicy = true, hostOS = 'linux') { + function setup(enabled = true, confirmPermissions = true, policyRestricted = false, publishSandboxPolicy = true, hostOS = 'linux', cloudDraft = false) { const config: ResolveSessionConfigResult = { schema: { type: 'object', @@ -77,10 +77,11 @@ suite('AgentHostModePicker', () => { configChanged.fire('test-session'); })); const provider = new class extends mock() { - override readonly id = 'local-agent-host'; + override readonly id = cloudDraft ? 'cloud-sandbox-creation' : 'local-agent-host'; + override readonly sessionConfig: ISessionConfigProvider | undefined = cloudDraft ? this : undefined; override readonly onDidChangeSessionConfig = configChanged.event; override getSessionConfig() { return config; } - override getCreateSessionConfig() { return undefined; } + override getCreateSessionConfig() { return cloudDraft ? config.values : undefined; } override getSessionSandboxPolicy() { return publishSandboxPolicy ? { enabled: managedSandboxEnforced.get() } : undefined; } override isDevContainerRequested() { return devContainer.get(); } override isSessionConfigResolving() { return resolving; } @@ -199,6 +200,23 @@ suite('AgentHostModePicker', () => { return { picker, trigger, config, configChanged, configuration, actionWidget, writes, session, phone, resolving, permissionDelegate, managedSandboxEnforced, settingsRequests, hoverTargets, devContainer }; } + test('reuses mode and permission controls for a cloud draft without an agent host connection', async () => { + const { config, configChanged, trigger, picker, actionWidget, writes, permissionDelegate } = setup(true, true, false, false, 'linux', true); + delete config.schema.properties.autoApprove; + delete config.schema.properties[SessionConfigKey.SandboxEnabled]; + config.schema.properties.approvalMode = { type: 'string', title: 'Approvals', enum: ['manual', 'assisted', 'allow-all'] }; + config.values = { mode: 'interactive', approvalMode: 'assisted' }; + configChanged.fire('test-session'); + picker.showPicker(trigger); + await actionWidget.select('Plan'); + await permissionDelegate.setPermissionLevel(ChatPermissionLevel.Default); + assert.deepStrictEqual({ combined: permissionDelegate.isModePickerCombined.get(), levels: permissionDelegate.availableLevels, writes, config: config.values }, { + combined: true, levels: ['default', 'assisted', 'autoApprove'], + writes: [{ session: 'test-session', property: 'mode', value: 'plan' }, { session: 'test-session', property: 'approvalMode', value: 'manual' }], + config: { mode: 'plan', approvalMode: 'manual' }, + }); + }); + test('updates the sandbox toggle, icon, and accessible label while Dev Container availability is pending', async () => { const { trigger, configuration, actionWidget, devContainer, config, writes } = setup(true, true, false, true, 'win32'); await configuration.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, 'on'); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 581357f6aa28..3a782e42e260 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -2634,6 +2634,38 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(agentHost.dispatchedActions, []); }); + test('initial configuration and model preferences reach the first send of a host-listed session', async () => { + const modelId = 'agent-host-copilotcli:configured-model'; + const metadata = { + ...createTestLanguageModel('configured-model'), targetChatSessionType: 'agent-host-copilotcli', + configurationSchema: { type: 'object' as const, properties: { reasoningEffort: { type: 'string' as const, enum: ['low', 'high'] } } }, + }; + let sent: object | undefined; + const provider = createProvider(disposables, agentHost, undefined, { + openSession: true, + languageModelIds: [modelId], + lookupLanguageModel: () => metadata, + acquireOrLoadSession: async () => new ImmortalReference(new class extends mock() { + override readonly inputModel = new class extends mock() { + override readonly state = constObservable(undefined); + override setState(): void { } + override clearState(): void { } + }(); + }()), + sendRequest: async (_resource, _message, options) => { + sent = { config: options?.agentHostSessionConfig, model: options?.userSelectedModelId, modelConfiguration: options?.userSelectedModelConfiguration }; + return { kind: 'sent' as const, data: {} as IChatSendRequestData }; + }, + }); + agentHost.addSession(createSession('initial-options', { summary: 'Initial Options' })); + fireSessionAdded(agentHost, 'initial-options', { title: 'Initial Options' }); + const session = provider.getSessions().find(session => session.title.get() === 'Initial Options')!; + provider.setModel(session.sessionId, session.resource, modelId, ChatModelSource.Chosen); + await provider.getAutomationModelConfiguration(session.sessionId)!.setModelConfiguration(modelId, { reasoningEffort: 'high' }); + await provider.sendRequest(session.sessionId, session.resource, { query: 'first turn', sessionConfig: { mode: 'plan', approvalMode: 'manual' } }); + assert.deepStrictEqual(sent, { config: { mode: 'plan', approvalMode: 'manual' }, model: modelId, modelConfiguration: { reasoningEffort: 'high' } }); + }); + test('setAgent updates existing session agent and lets draft debounce persist it', () => { const provider = createProvider(disposables, agentHost); fireSessionAdded(agentHost, 'set-agent', { title: 'Set Agent Session' }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md index bfd685d4ee98..1fda61276d02 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md @@ -54,7 +54,7 @@ The provider never opens chat UI directly. Presentation and focus remain owned b Each session has a single chat; the provider does not advertise multiple chats, rename, or delete. Follow-up turns go through the committed session's existing chat resource. -Sandbox creation reuses the remote draft and optimistic replacement lifecycle. Repository selection creates only a draft. The first send provisions through `CloudSandboxAgentHostContribution`, awaits the connection's advertised repository preparation, sends the prompt once into the provisioned session's existing main chat, and transfers ownership to that environment's provider. Until that handoff, the draft exposes shared `ISession.preparationProgress`; the chat view owns the transient, extension-independent preparation transcript. The creation provider does not supply the regular Cloud model catalog; the connected host owns model selection. Explicit sandbox drafts fail when sandbox creation is disabled rather than falling back to the server-run Cloud agent. If the first send fails after provisioning, the environment's session is published so it remains recoverable. +Sandbox creation reuses the remote draft and optimistic replacement lifecycle. Repository selection creates only a draft. The first send provisions through `CloudSandboxAgentHostContribution`, awaits the connection's advertised repository preparation, sends the prompt once into the provisioned session's existing main chat, and transfers ownership to that environment's provider. Until that handoff, the draft exposes shared `ISession.preparationProgress`; the chat view owns the transient, extension-independent preparation transcript. Sandbox drafts obtain their account-scoped model catalog from Mission Control independently of local runtimes and the Copilot extension. Connected sandbox model providers retain service-discovered models missing from the AHP catalog, including after restoration; explicit host metadata and policy take precedence for models the host does advertise. The creation provider retains session and model configuration, resolves session options with the connected host, and applies the selected options before dispatching the first turn. Explicit sandbox drafts fail when sandbox creation is disabled rather than falling back to the server-run Cloud agent. If the first send fails after provisioning, the environment's session is published so it remains recoverable. ## Picker contributions diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/cloudSandboxSessionConfig.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/cloudSandboxSessionConfig.ts new file mode 100644 index 000000000000..0aa054d7df9e --- /dev/null +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/cloudSandboxSessionConfig.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { isAutoApprovePolicyRestricted } from '../../../../../workbench/contrib/chat/common/agentHostConfigPolicy.js'; +import { ChatConfiguration, ChatDefaultPermissionLevel, IChatDefaultConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; + +/** Cloud draft vocabulary until the provisioned host supplies its authoritative schema. */ +export function createCloudSandboxSessionConfig(configurationService: IConfigurationService): ResolveSessionConfigResult { + const restricted = isAutoApprovePolicyRestricted(configurationService); + const modes = ['interactive', 'plan', 'autopilot']; + const approvals = restricted ? ['manual'] : ['manual', 'assisted', 'allow-all']; + const defaults = configurationService.getValue(ChatConfiguration.DefaultConfiguration); + const approval = defaults?.approvals === ChatDefaultPermissionLevel.AllowAll ? 'allow-all' : defaults?.approvals; + return { + schema: { + type: 'object', + properties: { + mode: { + type: 'string', + title: localize('cloudSandbox.mode', "Agent Mode"), + enum: modes, + enumLabels: [localize('cloudSandbox.interactive', "Interactive"), localize('cloudSandbox.plan', "Plan"), localize('cloudSandbox.autopilot', "Autopilot")], + enumDescriptions: [ + localize('cloudSandbox.interactiveDescription', "Works with you and asks for input when needed."), + localize('cloudSandbox.planDescription', "Plans the task before making changes."), + localize('cloudSandbox.autopilotDescription', "Continues working until the task is done."), + ], + default: 'interactive', + }, + approvalMode: { + type: 'string', + title: localize('cloudSandbox.approvals', "Approvals"), + enum: approvals, + default: restricted ? 'manual' : 'assisted', + }, + }, + }, + values: { + mode: defaults?.mode && modes.includes(defaults.mode) ? defaults.mode : 'interactive', + approvalMode: approval && approvals.includes(approval) ? approval : restricted ? 'manual' : 'assisted', + }, + }; +} diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts index c0e3581eaa1e..f1431fa4332f 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ActionViewItem, BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize2 } from '../../../../../nls.js'; import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; @@ -11,37 +11,41 @@ import { Action2, registerAction2 } from '../../../../../platform/actions/common import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../../../workbench/common/contributions.js'; import { getNewSessionRepositoryConfigGroup, Menus } from '../../../../browser/menus.js'; -import { SessionProviderIdContext, SessionTypeContext, IsNewChatSessionContext, NewSessionCreationProviderIdContext } from '../../../../common/contextkeys.js'; +import { SessionProviderIdContext, SessionTypeContext, IsNewChatSessionContext } from '../../../../common/contextkeys.js'; import { COPILOT_PROVIDER_ID, CopilotCloudSessionType } from './copilotChatSessionsProvider.js'; import { SandboxPicker } from './sandboxPicker.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { ISessionContext } from '../../../../services/sessions/browser/sessionContext.js'; import { CLOUD_SANDBOX_CREATION_PROVIDER_ID } from '../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; +import { AgentHostModePicker } from '../../agentHost/browser/agentHostModePicker.js'; +import { AgentHostPermissionPickerDelegate } from '../../agentHost/browser/agentHostPermissionPickerDelegate.js'; +import { PickerActionViewItem } from '../../agentHost/browser/agentHostSessionConfigPicker.js'; +import { MobilePermissionPicker } from './mobilePermissionPicker.js'; +import { MobileAgentHostModePicker } from '../../agentHost/browser/mobile/mobileAgentHostModePicker.js'; +import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js'; +import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; const IsActiveCopilotChatSessionProvider = ContextKeyExpr.equals(SessionProviderIdContext.key, COPILOT_PROVIDER_ID); const IsActiveSessionCopilotChatCloud = ContextKeyExpr.and(ContextKeyExpr.equals(SessionTypeContext.key, CopilotCloudSessionType.id), IsActiveCopilotChatSessionProvider); // -- Actions -- -registerAction2(class extends Action2 { - constructor() { - super({ - id: 'sessions.sandbox.agentDefaultModel', - title: localize2('sandbox.agentDefaultModel', "Agent Default"), - precondition: ContextKeyExpr.false(), - menu: [{ - id: Menus.NewSessionConfig, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(IsNewChatSessionContext, ChatContextKeys.enabled, ContextKeyExpr.or( - SessionProviderIdContext.isEqualTo(CLOUD_SANDBOX_CREATION_PROVIDER_ID), - NewSessionCreationProviderIdContext.isEqualTo(CLOUD_SANDBOX_CREATION_PROVIDER_ID), - )), - }], - }); - } - override run(): void { } -}); +const IsNewCloudSession = ContextKeyExpr.and(IsNewChatSessionContext, ChatContextKeys.enabled, ContextKeyExpr.or( + IsActiveCopilotChatSessionProvider, + SessionProviderIdContext.isEqualTo(CLOUD_SANDBOX_CREATION_PROVIDER_ID), +)); + +for (const [id, title, order] of [ + ['sessions.sandbox.modePicker', localize2('sandbox.modePicker', "Agent Mode"), 0], + ['sessions.sandbox.approvalPicker', localize2('sandbox.approvalPicker', "Approvals"), 1], +] as const) { + registerAction2(class extends Action2 { + constructor() { + super({ id, title, menu: [{ id: Menus.NewSessionControl, group: 'navigation', order, when: IsNewCloudSession }] }); + } + override run(): void { } + }); +} registerAction2(class extends Action2 { constructor() { @@ -66,7 +70,7 @@ registerAction2(class extends Action2 { * Wraps a standalone picker widget as a {@link BaseActionViewItem} * so it can be rendered by a {@link MenuWorkbenchToolBar}. */ -class PickerActionViewItem extends BaseActionViewItem { +class SandboxPickerActionViewItem extends BaseActionViewItem { constructor(private readonly picker: { render(container: HTMLElement): void; dispose(): void }) { super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }); } @@ -89,18 +93,24 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, ) { super(); - this._register(actionViewItemService.register( - Menus.NewSessionConfig, 'sessions.sandbox.agentDefaultModel', - action => new ActionViewItem(undefined, action, { icon: false, label: true }), - )); + this._register(actionViewItemService.register(Menus.NewSessionControl, 'sessions.sandbox.modePicker', (_action, _options, scopedInstantiationService) => { + const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); + return new PickerActionViewItem(scopedInstantiationService.createInstance(isPhoneLayout(layoutService) ? MobileAgentHostModePicker : AgentHostModePicker, session)); + })); + this._register(actionViewItemService.register(Menus.NewSessionControl, 'sessions.sandbox.approvalPicker', (_action, _options, scopedInstantiationService) => { + const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); + const delegate = scopedInstantiationService.createInstance(AgentHostPermissionPickerDelegate, session); + return new PickerActionViewItem(scopedInstantiationService.createInstance(MobilePermissionPicker, delegate), delegate, true); + })); this._register(actionViewItemService.register( Menus.NewSessionRepositoryConfig, 'sessions.defaultCopilot.sandboxPicker', (_action, _options, scopedInstantiationService) => { const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); const picker = scopedInstantiationService.createInstance(SandboxPicker, session); - return new PickerActionViewItem(picker); + return new SandboxPickerActionViewItem(picker); }, )); } diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 3131a7466b89..14c2bdc307db 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -12,7 +12,7 @@ import { Disposable, DisposableStore, IDisposable, DisposableMap, IReference, Mu import { Schemas } from '../../../../../base/common/network.js'; import { deepClone } from '../../../../../base/common/objects.js'; import { isWeb } from '../../../../../base/common/platform.js'; -import { constObservable, derived, IObservable, ISettableObservable, ITransaction, observableFromPromise, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js'; +import { autorun, constObservable, derived, IObservable, ISettableObservable, ITransaction, observableFromPromise, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -31,9 +31,9 @@ import { AutomationModelConfiguration } from '../../../automations/browser/autom import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, IGitHubIssueRef, ISessionArtifact, SessionArtifactKind, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement, ISessionChangesSummary, ISessionCreationReference, ISessionPreparationProgress } from '../../../../services/sessions/common/session.js'; import { linkKey } from '../../../../common/sessionLinks.js'; import { getSessionApplication } from '../../../../common/sessionApplication.js'; -import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; -import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionConfigProvider, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../../chat/browser/newSession.js'; import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../chat/common/constants.js'; import { CancellationToken, CancellationTokenSource, cancelOnDispose } from '../../../../../base/common/cancellation.js'; @@ -66,6 +66,10 @@ import { resolveGitRepositoryFromGitConfig } from '../../../../services/sessions import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; import { RepositoryPicker } from '../../../../../workbench/contrib/chat/browser/agentSessions/repositoryPicker.js'; import { ChatAIDisabledSettingId } from '../../../../../platform/chat/common/chatSettings.js'; +import { CloudSandboxModels } from '../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.js'; +import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { validateSessionConfigWrite } from '../../../../../platform/agentHost/common/sessionConfigProperties.js'; +import { createCloudSandboxSessionConfig } from './cloudSandboxSessionConfig.js'; /** Copilot Cloud session type - cloud-hosted agent. */ export const CopilotCloudSessionType: ISessionType = { @@ -400,6 +404,13 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession this.storageService.store(STORAGE_KEY_USE_SANDBOX, useSandbox, StorageScope.PROFILE, StorageTarget.MACHINE); } + observeSandbox(onChange: () => void): void { + this._register(autorun(reader => { + this.useSandbox.read(reader); + onChange(); + })); + } + setModelId(modelId: string | undefined, source: ChatModelSource): void { this._modelId = modelId; // One update, and both halves of it: a model and where it came from are only meaningful as @@ -1082,7 +1093,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * connecting and the host publishing its models, short enough not to strand a send behind a * catalog that is never coming. Exceeding it is reported to the user, not only logged. */ - private static readonly SANDBOX_MODEL_WAIT_MS = 5_000; + private static readonly SANDBOX_MODEL_WAIT_MS = 30_000; get id(): string { return this.providerMode === 'sandbox' ? CLOUD_SANDBOX_CREATION_PROVIDER_ID : COPILOT_PROVIDER_ID; } get label(): string { return this.providerMode === 'sandbox' ? localize('sandboxCreationProvider', "GitHub Sandboxes") : localize('copilotChatSessionsProvider', "Copilot Chat"); } @@ -1115,6 +1126,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions private readonly _inFlightCommits = new Set(); private readonly _sandboxSends = new Set(); private readonly _repositoryPicker = this._register(new MutableDisposable()); + private readonly _sandboxCatalog = this._register(new MutableDisposable()); + private readonly _onDidChangeSandboxModels = this._register(new Emitter()); + private readonly _sandboxConfigs = new Map(); + private readonly _onDidChangeSessionConfig = this._register(new Emitter()); + readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event; + readonly sessionConfig: ISessionConfigProvider = this; /** Cache of ISession wrappers, keyed by session ID. */ private readonly _sessionWrapperCache = new Map(); @@ -1154,6 +1171,20 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this.configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(UNIFIED_WORKSPACE_PICKER_SETTING), )(() => this._onDidChangeSessionTypes.fire())); + this._register(this.configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(ChatConfiguration.DefaultConfiguration) || event.affectsConfiguration(ChatConfiguration.GlobalAutoApprove)) { + for (const [sessionId, config] of this._sandboxConfigs) { + const refreshed = createCloudSandboxSessionConfig(this.configurationService); + for (const [key, value] of Object.entries(config.values)) { + if (refreshed.schema.properties[key]?.enum?.some(candidate => candidate === value)) { + refreshed.values[key] = value; + } + } + this._sandboxConfigs.set(sessionId, refreshed); + this._onDidChangeSessionConfig.fire(sessionId); + } + } + })); // Forward session changes from the underlying model this._register(this.agentSessionsService.model.onDidChangeSessions(() => { @@ -1303,6 +1334,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions */ private _clearCurrentNewSessionIfMatch(session: RemoteNewSession, leak?: boolean): void { if (this._newSessions.get(session.sessionId) === session) { + this._sandboxConfigs.delete(session.sessionId); if (leak) { this._newSessions.deleteAndLeak(session.sessionId); } else { @@ -1312,6 +1344,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } deleteNewSession(sessionId: string): void { + this._sandboxConfigs.delete(sessionId); if (this._newSessions.has(sessionId)) { this._newSessions.deleteAndDispose(sessionId); this._sessionWrapperCache.delete(sessionId); @@ -1356,6 +1389,13 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const session = this.instantiationService.createInstance(RemoteNewSession, resource, cloudWorkspace, target, this.id, automationConfiguration); session.setCreatedBySession(options?.createdBySession); this._newSessions.set(session.sessionId, session); + session.observeSandbox(() => { + if (this._isSandboxDraft(session)) { + this._getSandboxCatalog().load(); + } + this._onDidChangeSessionConfig.fire(session.sessionId); + this._onDidChangeSandboxModels.fire(); + }); try { this._applyAutomationSessionConfiguration(session, automationConfiguration); return this._chatToSession(session); @@ -1438,14 +1478,16 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Models can change because language models are (un)registered or because // the extension host updates a cloud session's `models` option group. return Event.signal(Event.any( + this._onDidChangeSandboxModels.event, this.languageModelsService.onDidChangeLanguageModels, this.chatSessionsService.onDidChangeOptionGroups )); } getModelsSnapshot(sessionId: string, desiredModelId?: string): ISessionModelsSnapshot { - if (this.providerMode === 'sandbox') { - return { models: [], desiredModelResolution: resolveModelIdentifier([], desiredModelId, true), modelTarget: CopilotSandboxSessionType.id }; + const draft = this._newSessions.get(sessionId); + if (draft && this._isSandboxDraft(draft)) { + return this._sandboxModelsSnapshot(desiredModelId); } const session = this.getSession(sessionId); if (session instanceof RemoteNewSession) { @@ -1474,9 +1516,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } getModelsSnapshotForCreation(_workspaceUri: URI, sessionTypeId: string, desiredModelId?: string): ISessionModelsSnapshot { - if (this.providerMode === 'sandbox' || sessionTypeId !== CopilotCloudSessionType.id) { - // Sandbox drafts use the connected host's catalog, and no other session types are offered. - return { models: [], desiredModelResolution: resolveModelIdentifier([], desiredModelId, true), modelTarget: this.providerMode === 'sandbox' ? CopilotSandboxSessionType.id : undefined }; + if (this.providerMode === 'sandbox') { + return this._sandboxModelsSnapshot(desiredModelId); + } + if (sessionTypeId !== CopilotCloudSessionType.id) { + return { models: [], desiredModelResolution: resolveModelIdentifier([], desiredModelId, true), modelTarget: undefined }; } const group = this.chatSessionsService.getOptionGroupsForSessionType(AgentSessionProviders.Cloud)?.find(candidate => isModelOptionGroup(candidate)); const models = group?.items.map((item): ILanguageModelChatMetadataAndIdentifier => this._toSyntheticModel(item)) ?? []; @@ -1488,9 +1532,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } getModelPickerOptions(sessionId: string): ISessionModelPickerOptions { - if (this.providerMode === 'sandbox') { - return { useGroupedModelPicker: false, showFeatured: false, showUnavailableFeatured: false, showManageModelsAction: false, showAutoModel: true }; + const draft = this._newSessions.get(sessionId); + if (draft && this._isSandboxDraft(draft)) { + return { useGroupedModelPicker: true, showFeatured: false, showUnavailableFeatured: false, showManageModelsAction: false, showAutoModel: false }; } + // A session type that requires an explicit model selection cannot fall // back to Auto. When it has no models, the picker shows a "No models // available" state instead. Derive this from the contribution's @@ -1506,6 +1552,56 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions }; } + private _isSandboxDraft(session: RemoteNewSession): boolean { + return this.providerMode === 'sandbox' || (session.useSandbox.get() === true && isCloudSandboxEnabled(this.configurationService)); + } + + private _getSandboxCatalog(): CloudSandboxModels { + if (!this._sandboxCatalog.value) { + const catalog = this.instantiationService.createInstance(CloudSandboxModels, CopilotSandboxSessionType.id, `${this.id}-sandbox-models`, undefined); + this._sandboxCatalog.value = catalog; + this._register(catalog.onDidChange(() => this._onDidChangeSandboxModels.fire())); + } + return this._sandboxCatalog.value; + } + + private _sandboxModelsSnapshot(desiredModelId?: string): ISessionModelsSnapshot { + const catalog = this._getSandboxCatalog(); + catalog.load(); + return { models: catalog.models, desiredModelResolution: resolveModelIdentifier(catalog.models, desiredModelId, catalog.ready), modelTarget: CopilotSandboxSessionType.id }; + } + + getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined { + const session = this._newSessions.get(sessionId); + if (!session || !this._isSandboxDraft(session)) { + return undefined; + } + let config = this._sandboxConfigs.get(sessionId); + if (!config) { + config = createCloudSandboxSessionConfig(this.configurationService); + this._sandboxConfigs.set(sessionId, config); + } + return config; + } + + getCreateSessionConfig(sessionId: string): Record | undefined { + return this.getSessionConfig(sessionId)?.values; + } + + isSessionConfigResolving(_sessionId: string): IObservable { + return constObservable(false); + } + + async setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise { + const config = this.getSessionConfig(sessionId); + if (!config) { + throw new Error('Cloud sandbox draft configuration is unavailable.'); + } + validateSessionConfigWrite(config.schema, config.values, property, value, true); + this._sandboxConfigs.set(sessionId, { ...config, values: { ...config.values, [property]: value } }); + this._onDidChangeSessionConfig.fire(sessionId); + } + private _toSyntheticModel(item: IChatSessionProviderOptionItem): ILanguageModelChatMetadataAndIdentifier { const modelMetadata = item.modelMetadata; return { @@ -1725,7 +1821,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions let provisioned: ICloudSandboxProvisionedSession | undefined; // Read before provisioning: the composer session is retired below, and its selection is the // only record of what the user picked for this turn. - const selectedModel = this.providerMode === 'sandbox' ? undefined : this._selectedCloudModel(session); + const selectedModel = this._selectedCloudModel(session); + const config = this.getCreateSessionConfig(session.sessionId); const store = new DisposableStore(); store.add(preparation); store.add(token.onCancellationRequested(() => store.dispose())); @@ -1753,10 +1850,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Send into the session's main chat rather than `createNewChat`, which would mint an // *additional* peer chat inside a session that already has one. const chat = provisioned.session.mainChat.get(); + const sessionConfig = config ? await provisioned.provider.resolveInitialSessionConfig(provisioned.session.sessionId, config, token) : undefined; await raceCancellationError(this._carryModelToSandbox(provisioned, chat.resource, selectedModel), token); // Preserve the prompt-derived title rather than the host's branch-name default. await raceCancellationError(provisioned.provider.renameSession(provisioned.session.sessionId, session.title.get()), token); - const committed = await provisioned.provider.sendRequest(provisioned.session.sessionId, chat.resource, options); + const committed = await provisioned.provider.sendRequest(provisioned.session.sessionId, chat.resource, { ...options, sessionConfig }); // Retire only once the turn is dispatched; swapping earlier bounces the view home. this._publishSandboxSession(provisioned, { announce: false }); @@ -1784,23 +1882,21 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions private _publishSandboxSession(provisioned: ICloudSandboxProvisionedSession, options?: { announce?: boolean }): void { provisioned.provider.publishWithheldSession(AgentSession.id(provisioned.session.resource), options); } - - /** - * The composer's model selection as the sandbox knows it, plus the label to name it by. - * - * Cloud sessions pick from the extension host's `models` option group, whose ids are the - * group's own item ids, while a sandbox registers its models from what the agent host - * advertises. Different id spaces, so only the underlying model id crosses over. - * - * Only the model, because only the model exists: an option item's `modelMetadata` is hover and - * pricing detail with no configuration schema, so a cloud composer never offers a thinking - * level or context tier to carry alongside it. - */ - private _selectedCloudModel(session: RemoteNewSession): { readonly rawModelId: string; readonly label: string } | undefined { + /** Carries the raw model ID and its preferences across the draft and connected host's identifier spaces. */ + private _selectedCloudModel(session: RemoteNewSession): { readonly rawModelId: string; readonly label: string; readonly config?: IAutomationSessionTemplate['modelConfiguration']; readonly fromCloudCatalog?: boolean } | undefined { const selectedModelId = session.selectedModelId; if (!selectedModelId) { return undefined; } + const sandboxModel = this._sandboxCatalog.value?.models.find(model => model.identifier === selectedModelId); + if (sandboxModel) { + return { + rawModelId: sandboxModel.metadata.id, + label: sandboxModel.metadata.name, + config: session.modelConfiguration.captureModelConfiguration(selectedModelId), + fromCloudCatalog: true, + }; + } const { modelOption } = session.getModelOptionsSnapshot(); const item = modelOption?.group.items.find(i => i.id === selectedModelId); const rawModelId = item?.modelMetadata?.id ?? item?.id ?? selectedModelId; @@ -1821,7 +1917,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * Every path that gives up tells the user: an absent `Message.model` means "host decides", so * nothing downstream would report running at a capability and price they did not choose. */ - private async _carryModelToSandbox(provisioned: ICloudSandboxProvisionedSession, chatResource: URI, selected: { readonly rawModelId: string; readonly label: string } | undefined): Promise { + private async _carryModelToSandbox(provisioned: ICloudSandboxProvisionedSession, chatResource: URI, selected: { readonly rawModelId: string; readonly label: string; readonly config?: IAutomationSessionTemplate['modelConfiguration']; readonly fromCloudCatalog?: boolean } | undefined): Promise { if (!selected) { return; } @@ -1845,10 +1941,24 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions for (; ;) { const resolution = provider.getModelsSnapshot(sessionId, desiredModelId).desiredModelResolution; if (resolution.kind === 'available') { + if (selected.config && Object.keys(selected.config).length) { + const schema = resolution.model.metadata.configurationSchema; + for (const [key, value] of Object.entries(selected.config)) { + const property = schema?.properties?.[key]; + if (!property || property.readOnly || (property.enum && !property.enum.includes(value))) { + throw new Error(localize('sandbox.modelConfigurationUnavailable', "The sandbox does not support the selected {0} for {1}.", key, label)); + } + } + const configuration = provider.getAutomationModelConfiguration(sessionId); + if (!configuration) { + throw new Error('The sandbox model configuration is unavailable.'); + } + await configuration.setModelConfiguration(resolution.model.identifier, selected.config); + } provider.setModel(sessionId, chatResource, resolution.model.identifier, ChatModelSource.CarriedOver); return; } - if (resolution.kind !== 'pending') { + if (resolution.kind !== 'pending' && !selected.fromCloudCatalog) { this.logService.info(`[CopilotChatSessionsProvider] Sandbox session ${sessionId} does not advertise model '${rawModelId}'; letting the agent host choose.`); this._notifySandboxModelNotApplied(label); return; diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts index 720306e85645..ef31d61ba5e3 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts @@ -266,6 +266,16 @@ export class PermissionPicker extends Disposable { return slot; } + focus(): void { + this._triggerElement?.focus(); + } + + setFocusable(focusable: boolean): void { + if (this._triggerElement) { + this._triggerElement.tabIndex = focusable ? 0 : -1; + } + } + showPicker(): void { this._showPicker(); } diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 8a244e959cfc..022af065b2b0 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -43,7 +43,7 @@ import { CloudSandboxEnabledSettingId, type ICloudSandboxCreateSessionRequest } import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { CLOUD_SANDBOX_CREATION_PROVIDER_ID, CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; import { CloudSandboxSessionsProvider } from '../../../remoteAgentHost/browser/cloudSandboxSessionsProvider.js'; -import { ChatModeKind, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; +import { ChatConfiguration, ChatDefaultPermissionLevel, ChatModeKind, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../../chat/common/constants.js'; import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID, CopilotCloudSessionType, CopilotSandboxSessionType, RemoteNewSession } from '../../browser/copilotChatSessionsProvider.js'; import { ChatAIDisabledSettingId } from '../../../../../../platform/chat/common/chatSettings.js'; @@ -62,6 +62,10 @@ import { RepositoryPicker } from '../../../../../../workbench/contrib/chat/brows import { GitHubPullRequestModel } from '../../../../github/browser/models/githubPullRequestModel.js'; import { IPullRequestIconCache } from '../../../../github/browser/pullRequestIconCache.js'; import { computePullRequestIcon, GitHubPullRequestState, IGitHubPullRequest, IGitHubRepository } from '../../../../github/common/types.js'; +import { CloudSandboxModels } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.js'; +import { createCloudSandboxSessionConfig } from '../../browser/cloudSandboxSessionConfig.js'; +import { AutomationModelConfiguration } from '../../../../automations/browser/automationModelConfiguration.js'; +import { validateSessionConfigWrite } from '../../../../../../platform/agentHost/common/sessionConfigProperties.js'; // ---- Helpers ---------------------------------------------------------------- @@ -314,6 +318,7 @@ function createProviderWithConfig( opts?: ICreateProviderOptions, ): { provider: CopilotChatSessionsProvider; configService: TestConfigurationService; labelService: MockLabelService } { const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stubInstance(CloudSandboxModels, upcastPartial({ models: [], ready: true, onDidChange: Event.None, load: () => { }, dispose: () => { } })); const configService = new TestConfigurationService(); configService.setUserConfiguration(UNIFIED_WORKSPACE_PICKER_SETTING, opts?.consolidatedRemoteWorkspaces ?? false); @@ -402,9 +407,10 @@ function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise, - opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; notifications?: string[]; languageModelsService?: Partial; providerMode?: 'default' | 'sandbox'; onGetChatSession?: () => void; chatContentProviders?: IChatSessionContentProvider[]; storageService?: IStorageService }, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; notifications?: string[]; languageModelsService?: Partial; providerMode?: 'default' | 'sandbox'; onGetChatSession?: () => void; chatContentProviders?: IChatSessionContentProvider[]; storageService?: IStorageService; sandboxModels?: readonly ILanguageModelChatMetadataAndIdentifier[] }, ): TestSandboxCopilotProvider { const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stubInstance(CloudSandboxModels, upcastPartial({ models: opts?.sandboxModels ?? [], ready: true, onDidChange: Event.None, load: () => { }, dispose: () => { } })); const configService = opts?.configurationService ?? new TestConfigurationService(); @@ -441,7 +447,7 @@ function createProviderForSendTests( removeHistoryEntry: async (resource: URI) => { model.removeSession(resource); }, setChatSessionTitle: () => { }, }); - instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined, getModelConfiguration: () => undefined, setModelConfiguration: async () => { }, ...opts?.languageModelsService }); + instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: id => opts?.sandboxModels?.find(model => model.identifier === id)?.metadata, getModelConfiguration: () => undefined, setModelConfiguration: async () => { }, ...opts?.languageModelsService }); instantiationService.stub(INotificationService, new class extends mock() { override warn(message: unknown): void { opts?.notifications?.push(String(message)); } }()); @@ -2529,7 +2535,33 @@ suite('CopilotChatSessionsProvider', () => { // `repoNwo` has to strip back down to `owner/repo`. const repoWorkspace = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/osortega/simple-server/HEAD' }); - function createSandboxProvider(opts: { enabled?: boolean; provision?: CloudSandboxAgentHostContribution['provisionSession']; prepare?: CloudSandboxAgentHostContribution['prepareSession']; trackProgress?: CloudSandboxAgentHostContribution['trackSessionCreationProgress']; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; providerMode?: 'default' | 'sandbox'; onGetChatSession?: () => void } = {}) { + test('seeds cloud approvals from each default configuration value', () => { + const values = [ChatDefaultPermissionLevel.Manual, ChatDefaultPermissionLevel.Assisted, ChatDefaultPermissionLevel.AllowAll, undefined].map(approvals => { + const configuration = new TestConfigurationService({ [ChatConfiguration.DefaultConfiguration]: { approvals } }); + disposables.add(configuration.onDidChangeConfigurationEmitter); + return createCloudSandboxSessionConfig(configuration).values.approvalMode; + }); + assert.deepStrictEqual(values, ['manual', 'assisted', 'allow-all', 'assisted']); + }); + + test('enterprise policy clamps an Allow All default to Manual', () => { + const configuration = new class extends TestConfigurationService { + override inspect(key: string) { + const inspected = super.inspect(key); + return { ...inspected, policyValue: key === ChatConfiguration.GlobalAutoApprove ? inspected.value : undefined }; + } + }({ + [ChatConfiguration.DefaultConfiguration]: { approvals: ChatDefaultPermissionLevel.AllowAll }, + [ChatConfiguration.GlobalAutoApprove]: false, + }); + disposables.add(configuration.onDidChangeConfigurationEmitter); + const config = createCloudSandboxSessionConfig(configuration); + assert.deepStrictEqual({ values: config.values, approvals: config.schema.properties.approvalMode.enum }, { + values: { mode: 'interactive', approvalMode: 'manual' }, approvals: ['manual'], + }); + }); + + function createSandboxProvider(opts: { enabled?: boolean; provision?: CloudSandboxAgentHostContribution['provisionSession']; prepare?: CloudSandboxAgentHostContribution['prepareSession']; trackProgress?: CloudSandboxAgentHostContribution['trackSessionCreationProgress']; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; providerMode?: 'default' | 'sandbox'; onGetChatSession?: () => void; sandboxModels?: readonly ILanguageModelChatMetadataAndIdentifier[] } = {}) { const configurationService = new TestConfigurationService(); configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, opts.enabled ?? true); configurationService.setUserConfiguration(RemoteAgentHostsEnabledSettingId, true); @@ -2541,7 +2573,7 @@ suite('CopilotChatSessionsProvider', () => { cloudSends.push(message); // Never settles: these tests only assert which path the send took. return new Promise(() => { }); - }, { configurationService, getOptionGroups: opts.getOptionGroups, notifications, providerMode: opts.providerMode, onGetChatSession: opts.onGetChatSession, chatContentProviders }); + }, { configurationService, getOptionGroups: opts.getOptionGroups, notifications, providerMode: opts.providerMode, onGetChatSession: opts.onGetChatSession, chatContentProviders, sandboxModels: opts.sandboxModels }); const provisionRequests: ICloudSandboxCreateSessionRequest[] = []; provider.sandboxContribution = { @@ -2565,7 +2597,7 @@ suite('CopilotChatSessionsProvider', () => { * resolution reports `pending` until it yields the model, mirroring an agent host that has * connected but not yet published. */ - function provisionedSession(sendRequest?: CloudSandboxSessionsProvider['sendRequest'], sandboxModels: () => readonly ILanguageModelChatMetadataAndIdentifier[] = () => []): ICloudSandboxProvisionedSession & { published: string[]; renames: { sessionId: string; title: string }[]; modelSelections: { modelId: string; source: ChatModelSource }[]; modelsChanged: Emitter } { + function provisionedSession(sendRequest?: CloudSandboxSessionsProvider['sendRequest'], sandboxModels: () => readonly ILanguageModelChatMetadataAndIdentifier[] = () => []): ICloudSandboxProvisionedSession & { published: string[]; renames: { sessionId: string; title: string }[]; modelSelections: { modelId: string; source: ChatModelSource }[]; modelsChanged: Emitter; configurations: Record[]; modelConfigurations: Record[] } { const title = observableValue('title', 'main'); const committed = upcastPartial({ sessionId: 'agenthost:sess-new', @@ -2582,6 +2614,12 @@ suite('CopilotChatSessionsProvider', () => { const renames: { sessionId: string; title: string }[] = []; const modelSelections: { modelId: string; source: ChatModelSource }[] = []; const modelsChanged = disposables.add(new Emitter()); + const configurations: Record[] = []; + const modelConfigurations: Record[] = []; + const config = createCloudSandboxSessionConfig(new TestConfigurationService()); + for (const property of Object.values(config.schema.properties)) { + property.sessionMutable = true; + } return { taskId: 'task-new', sessionId: 'sess-new', @@ -2591,6 +2629,8 @@ suite('CopilotChatSessionsProvider', () => { renames, modelSelections, modelsChanged, + configurations, + modelConfigurations, provider: upcastPartial({ sendRequest: sendRequest ?? (async () => committed), renameSession: async (sessionId, newTitle) => { @@ -2599,6 +2639,18 @@ suite('CopilotChatSessionsProvider', () => { }, publishWithheldSession: (rawId: string) => { published.push(rawId); }, onDidChangeModels: modelsChanged.event, + onDidChangeSessionConfig: Event.None, + getSessionConfig: () => config, + resolveInitialSessionConfig: async (_sessionId, values) => { + for (const [key, value] of Object.entries(values)) { + validateSessionConfigWrite(config.schema, config.values, key, value, true); + } + configurations.push(values); + return values; + }, + getAutomationModelConfiguration: () => upcastPartial({ + setModelConfiguration: async (_id, values) => { modelConfigurations.push(values); }, + }), getModelsSnapshot: (_sessionId: string, desiredModelId?: string) => { const models = sandboxModels(); const model = models.find(m => m.identifier === desiredModelId); @@ -2820,6 +2872,86 @@ suite('CopilotChatSessionsProvider', () => { }); suite('explicit browser sandbox creation', () => { + test('waits for cloud-service models even when the host already published a smaller catalog', async () => { + const cloudModel = { ...sandboxModel('claude-sonnet-4.6'), identifier: 'cloud-catalog:claude-sonnet-4.6' }; + const models = [sandboxModel('auto')]; + const provisioned = provisionedSession(undefined, () => models); + const { provider, notifications } = createSandboxProvider({ providerMode: 'sandbox', sandboxModels: [cloudModel], provision: async () => provisioned }); + const waiting = new DeferredPromise(); + const originalGetModels = provisioned.provider.getModelsSnapshot; + provisioned.provider.getModelsSnapshot = (sessionId, desired) => { + const result = originalGetModels(sessionId, desired); + if (desired && result.desiredModelResolution.kind === 'unavailable') { + waiting.complete(); + } + return result; + }; + const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); + provider.setModel(draft.sessionId, draft.resource, cloudModel.identifier, ChatModelSource.Chosen); + const sent = provider.sendRequest(draft.sessionId, draft.resource, { query: 'hello' }); + await waiting.p; + models.push(sandboxModel('claude-sonnet-4.6')); + provisioned.modelsChanged.fire(); + await sent; + assert.deepStrictEqual({ models: provisioned.modelSelections, notifications }, { + models: [{ modelId: 'agent-host-copilot:claude-sonnet-4.6', source: ChatModelSource.CarriedOver }], notifications: [], + }); + }); + test('uses cloud models and sends mode, approvals, and reasoning before the first turn', async () => { + const schema = { type: 'object' as const, properties: { reasoningEffort: { type: 'string' as const, enum: ['low', 'high'] } } }; + const cloudModel = { ...sandboxModel('new-model'), identifier: 'cloud-catalog:new-model', metadata: { ...sandboxModel('new-model').metadata, configurationSchema: schema } }; + const hostModel = { ...sandboxModel('new-model'), metadata: { ...sandboxModel('new-model').metadata, configurationSchema: schema } }; + let sentWith: object | undefined; + let prepared = false; + const provisioned = provisionedSession(async (_sessionId, _chatResource, options) => { + sentWith = { prepared, title: provisioned.session.title.get(), sessionConfig: options.sessionConfig, configurations: [...provisioned.configurations], models: [...provisioned.modelSelections], modelConfigurations: [...provisioned.modelConfigurations] }; + return provisioned.session; + }, () => [hostModel]); + const { provider } = createSandboxProvider({ providerMode: 'sandbox', sandboxModels: [cloudModel], provision: async () => provisioned, prepare: async () => { prepared = true; } }); + const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); + provider.setModel(draft.sessionId, draft.resource, cloudModel.identifier, ChatModelSource.Chosen); + await provider.getAutomationModelConfiguration(draft.sessionId)!.setModelConfiguration(cloudModel.identifier, { reasoningEffort: 'high' }); + await provider.setSessionConfigValue(draft.sessionId, 'mode', 'plan'); + await provider.setSessionConfigValue(draft.sessionId, 'approvalMode', 'manual'); + await provider.sendRequest(draft.sessionId, draft.resource, { query: 'make a plan' }); + assert.deepStrictEqual(sentWith, { + prepared: true, + title: 'make a plan', + sessionConfig: { mode: 'plan', approvalMode: 'manual' }, + configurations: [{ mode: 'plan', approvalMode: 'manual' }], + models: [{ modelId: hostModel.identifier, source: ChatModelSource.CarriedOver }], + modelConfigurations: [{ reasoningEffort: 'high' }], + }); + }); + + test('keeps sandbox controls and catalog out of ordinary cloud drafts', () => { + const cloudModel = sandboxModel('sandbox-only'); + const { provider } = createSandboxProvider({ sandboxModels: [cloudModel], getOptionGroups: () => cloudModelOptionGroup('ordinary-cloud', 'ordinary-cloud') }); + const draft = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + const session = provider.getSession(draft.sessionId)!; + session.setUseSandbox(false); + const ordinary = { models: provider.getModelsSnapshot(draft.sessionId).models.map(model => model.metadata.id), config: provider.getSessionConfig(draft.sessionId)?.values }; + session.setUseSandbox(true); + const sandbox = { models: provider.getModelsSnapshot(draft.sessionId).models.map(model => model.metadata.id), config: provider.getSessionConfig(draft.sessionId)?.values }; + session.setUseSandbox(false); + assert.deepStrictEqual({ ordinary, sandbox, restored: provider.getSessionConfig(draft.sessionId) }, { + ordinary: { models: ['ordinary-cloud'], config: undefined }, + sandbox: { models: ['sandbox-only'], config: { mode: 'interactive', approvalMode: 'assisted' } }, + restored: undefined, + }); + }); + + test('does not send when the host rejects the requested configuration', async () => { + let sent = false; + const provisioned = provisionedSession(async () => { sent = true; return provisioned.session; }); + const hostConfig = provisioned.provider.getSessionConfig('agenthost:sess-new')!; + hostConfig.schema.properties.mode.enum = ['interactive']; + const { provider } = createSandboxProvider({ providerMode: 'sandbox', provision: async () => provisioned }); + const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); + await provider.setSessionConfigValue(draft.sessionId, 'mode', 'autopilot'); + await assert.rejects(provider.sendRequest(draft.sessionId, draft.resource, { query: 'work' }), /does not offer/); + assert.deepStrictEqual({ sent, published: provisioned.published }, { sent: false, published: ['sess-new'] }); + }); test('creates a repository-only draft without allocating or loading an extension chat', async () => { model.addSession(createMockAgentSession(URI.parse('copilot-cloud-agent:/existing-cloud'), { providerType: AgentSessionProviders.Cloud })); model.addSession(createMockAgentSession(URI.parse('copilotcli:/existing-cli'), { providerType: AgentSessionProviders.Background })); @@ -2875,7 +3007,6 @@ suite('CopilotChatSessionsProvider', () => { const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); const session = provider.getSession(draft.sessionId)!; session.setUseSandbox(false); - provider.setModel(draft.sessionId, draft.resource, 'cloud-only-model', ChatModelSource.Chosen); const replacements: string[] = []; disposables.add(provider.onDidReplaceSession(event => replacements.push(event.to.sessionId))); const request = provider.sendRequest(draft.sessionId, draft.resource, { query: 'fix it' }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts index d52d1bd9aaf7..d03b98958537 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts @@ -30,6 +30,7 @@ import { TestStorageService } from '../../../../../../workbench/test/common/work import { IWorkbenchLayoutService } from '../../../../../../workbench/services/layout/browser/layoutService.js'; import { DEFAULT_PERMISSION_LEVELS, getPermissionLevelMeta, IPermissionPickerDelegate, PermissionPicker } from '../../browser/permissionPicker.js'; import { MobilePermissionPicker } from '../../browser/mobilePermissionPicker.js'; +import { PickerActionViewItem } from '../../../agentHost/browser/agentHostSessionConfigPicker.js'; suite('Copilot PermissionPicker', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -41,6 +42,34 @@ suite('Copilot PermissionPicker', () => { managedSandboxAllowsBypass: constObservable(false), }; + test('toolbar focus and focusability target the permission trigger rather than its slot', () => { + const container = dom.append(document.body, dom.$('div')); + store.add(toDisposable(() => container.remove())); + const configuration = new TestConfigurationService(); + store.add(configuration.onDidChangeConfigurationEmitter); + const picker = new PermissionPicker( + { getPermissionLevelMeta: (_level, meta) => meta, setPermissionLevel: () => { } }, + new class extends mock() { }(), + configuration, + new TestDialogService(), + new class extends mock() { }(), + store.add(new TestStorageService()), + NullTelemetryService, + new class extends mock() { }(), + unmanagedEnablementService, + ); + const item = store.add(new PickerActionViewItem(picker, undefined, true)); + item.render(container); + const trigger = container.querySelector('.action-label')!; + item.setFocusable(false); + const disabledTabIndex = trigger.tabIndex; + item.setFocusable(true); + item.focus(); + assert.deepStrictEqual({ disabledTabIndex, enabledTabIndex: trigger.tabIndex, focused: document.activeElement === trigger, itemFocused: item.isFocused() }, { + disabledTabIndex: -1, enabledTabIndex: 0, focused: true, itemFocused: true, + }); + }); + for (const policyRestricted of [false, true]) { test(`labels Assisted permissions as experimental on phones${policyRestricted ? ' while honoring enterprise policy' : ''}`, async () => { const container = dom.append(document.body, dom.$('.phone-layout')); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts index 4700544793e5..17bddcaf4b38 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Sequencer } from '../../../../../base/common/async.js'; +import { raceCancellationError, Sequencer } from '../../../../../base/common/async.js'; import { CancellationError } from '../../../../../base/common/errors.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../../base/common/observable.js'; @@ -15,6 +15,9 @@ import { StorageScope, StorageTarget } from '../../../../../platform/storage/com import type { ISession } from '../../../../services/sessions/common/session.js'; import type { AgentHostSessionAdapter } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { CLOUD_SANDBOX_AGENT_PROVIDER } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { validateSessionConfigWrite } from '../../../../../platform/agentHost/common/sessionConfigProperties.js'; /** * Sessions provider for a Copilot cloud sandbox. @@ -54,6 +57,23 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide /** How long a provisional session resists eviction after the host first omits it. */ static readonly PROVISIONAL_GRACE_MS = 2 * 60_000; + /** Resolves creation options without requiring the host to know the preallocated session yet. */ + async resolveInitialSessionConfig(sessionId: string, values: Record, token: CancellationToken): Promise> { + const rawId = this._rawIdFromChatId(sessionId); + const connection = this.connection; + if (!connection || !rawId || !this._sessionCache.has(rawId)) { + throw new Error(localize('cloudSandbox.configUnavailable', "The sandbox connection is unavailable. Your prompt was not sent.")); + } + const config = await raceCancellationError(connection.resolveSessionConfig({ provider: CLOUD_SANDBOX_AGENT_PROVIDER, config: values }), token); + for (const [key, value] of Object.entries(values)) { + validateSessionConfigWrite(config.schema, config.values, key, value, true); + if (config.values[key] !== value) { + throw new Error(localize('cloudSandbox.configNotApplied', "The sandbox could not apply the selected {0}. Your prompt was not sent.", key)); + } + } + return { ...values }; + } + protected override _adapterOptions() { return { ...super._adapterOptions(), diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index a11e6e5bdf5b..6ac6fbdfd5ee 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -3540,6 +3540,37 @@ suite('CloudSandboxSessionsProvider discovery metadata', () => { }) as CloudSandboxSessionsProvider; } + test('resolves initial configuration before the host knows the provisional session', async () => { + const provider = createSandboxProvider(); + provider.seedProvisionalSession(metadata); + provider.setConnection(connection); + connection.resolveSessionConfigResult = { + schema: { + type: 'object', properties: { + mode: { type: 'string', title: 'Mode', enum: ['interactive', 'plan', 'autopilot'] }, + approvalMode: { type: 'string', title: 'Approvals', enum: ['manual', 'assisted', 'allow-all'] }, + } + }, + values: { mode: 'plan', approvalMode: 'manual' }, + }; + const session = provider.getCachedSession('discovered-session')!; + const values = await provider.resolveInitialSessionConfig(session.sessionId, { mode: 'plan', approvalMode: 'manual' }, CancellationToken.None); + assert.deepStrictEqual({ values, created: connection.createdSessionUris, dispatched: connection.dispatchedActions }, { + values: { mode: 'plan', approvalMode: 'manual' }, created: [], dispatched: [], + }); + }); + + test('rejects a host that cannot apply initial configuration', async () => { + const provider = createSandboxProvider(); + provider.seedProvisionalSession(metadata); + provider.setConnection(connection); + connection.resolveSessionConfigResult = { + schema: { type: 'object', properties: { mode: { type: 'string', title: 'Mode', enum: ['interactive', 'plan'] } } }, + values: { mode: 'interactive' }, + }; + await assert.rejects(provider.resolveInitialSessionConfig(provider.getCachedSession('discovered-session')!.sessionId, { mode: 'plan' }, CancellationToken.None), /could not apply/); + }); + test('opts out of workspace selection while retaining workspace resolution', () => { const provider = createSandboxProvider(); const uri = toAgentHostUri(URI.file('/workspace'), agentHostAuthority(provider.remoteAddress)); diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 288f3c150607..1c734dee3aec 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -9,6 +9,7 @@ import { IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; +import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ILanguageModelChatMetadataAndIdentifier, type IModelConfigurationAccess } from '../../../../workbench/contrib/chat/common/languageModels.js'; @@ -29,6 +30,16 @@ export interface ISessionChangeEvent { /** Why a session resource is being resolved, so a provider can pick a latency budget. */ export type SessionResourceResolveReason = 'open' | 'restore'; +/** Schema-backed configuration consumed by mode and approval pickers, including pre-provisioning drafts. */ +export interface ISessionConfigProvider { + readonly onDidChangeSessionConfig: Event; + getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined; + getCreateSessionConfig(sessionId: string): Record | undefined; + isSessionConfigResolving(sessionId: string): IObservable; + setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise; + trackSessionConfigOperation?(sessionId: string, operation: Promise): void; +} + /** Provider-owned permission choice exposed while configuring a new session. */ export interface ISessionPermissionOption { readonly id: string; @@ -52,6 +63,8 @@ export interface IPreparedNewSession { * Options for sending a request to a session. */ export interface ISendRequestOptions { + /** Initial configuration resolved by the target provider before its first turn. */ + readonly sessionConfig?: Readonly>; /** UI-only response observation, forwarded to the chat service rather than the backend. */ readonly onDidCreateResponse?: IChatSendRequestOptions['onDidCreateResponse']; /** The query text to send. */ @@ -174,6 +187,7 @@ export interface IDeleteChatOptions { * serve the same session type (e.g., one per remote agent host). */ export interface ISessionsProvider { + readonly sessionConfig?: ISessionConfigProvider; /** * Unique identifier for the provider. */ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index 30c3274c94d8..9d658d255624 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -57,6 +57,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu readonly onDidChange = this._onDidChange.event; private _models: readonly SessionModelInfo[] = []; + private _additionalModels: readonly SessionModelInfo[] = []; constructor( private readonly _sessionType: string, @@ -89,8 +90,15 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu this._onDidChange.fire(); } + /** Adds service-discovered models without overriding metadata or policy published by the host. */ + updateAdditionalModels(models: readonly SessionModelInfo[]): void { + this._additionalModels = models; + this._onDidChange.fire(); + } + async provideLanguageModelChatInfo(_options: unknown, _token: CancellationToken): Promise { - return this._models + const hostModelIds = new Set(this._models.map(model => model.id)); + return [...this._models, ...this._additionalModels.filter(model => !hostModelIds.has(model.id))] .filter(m => m.policyState !== 'disabled') .map(m => { const pricing = readAgentModelPricingMeta(m); diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts index d1c3de35b422..b211a193a547 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts @@ -9,6 +9,7 @@ import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { CLOUD_SANDBOX_AGENT_SLUG, @@ -24,9 +25,12 @@ import { ICloudSandboxDiscoveredSession, ICloudSandboxDiscoveryResult, ICloudSandboxEnvironment, + ICloudSandboxModelCatalog, } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { IReplayedTaskHistory, parseTaskEventsResponse, replayTaskAhpEvents, TaskEventReplayError } from '../../../../../platform/agentHost/common/taskEventReplay.js'; import { SessionStatus } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionModelInfo } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../../../../platform/agentHost/common/meta/agentModelMeta.js'; import { COPILOT_INTEGRATION_ID } from '../../../../../platform/endpoint/common/licenseAgreement.js'; import { GITHUB_DOT_COM_COPILOT_API_BASE_URI, deriveGitHubEndpoints } from '../../../../../platform/github/common/githubEndpoints.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -79,6 +83,20 @@ interface ICachedSandboxTask { const LOG_PREFIX = '[CloudSandboxApi]'; +interface ICloudModel { + readonly id: string; + readonly name: string; + readonly model_picker_enabled?: boolean; + readonly policy?: { readonly state?: string }; + readonly billing?: unknown; + readonly model_picker_category?: string; + readonly model_picker_price_category?: string; + readonly capabilities?: { + readonly limits?: { readonly max_context_window_tokens?: number; readonly max_prompt_tokens?: number; readonly max_output_tokens?: number }; + readonly supports?: { readonly vision?: boolean; readonly reasoning_effort?: readonly string[] }; + }; +} + function taskSessionStatus(state: string | undefined, logService: ILogService): SessionStatus | undefined { switch (state) { case 'queued': @@ -798,6 +816,54 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA return `${GITHUB_DOT_COM_COPILOT_API_BASE_URI}/agents`; } + async listModels(token: CancellationToken): Promise { + const context = await this._request(`${this._tasksBaseUrl()}/swe/models`, 'mc.models.list', 'listModels', { + 'Accept': 'application/json', + 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, + }, token, DISCOVERY_TIMEOUT_MS); + if (!isSuccess(context)) { + await this._throwForStatus('model catalog', context); + } + const catalog = await this._readJson<{ data?: ICloudModel[]; default_model?: string }>(context); + if (!catalog || !Array.isArray(catalog.data) || (catalog.default_model !== undefined && typeof catalog.default_model !== 'string')) { + throw new Error('Mission Control returned an invalid model catalog.'); + } + const models: SessionModelInfo[] = []; + for (const model of catalog.data) { + const efforts = model?.capabilities?.supports?.reasoning_effort; + if (!model || typeof model.id !== 'string' || !model.id || typeof model.name !== 'string' + || (efforts !== undefined && (!Array.isArray(efforts) || !efforts.every(value => typeof value === 'string')))) { + throw new Error('Mission Control returned invalid model metadata.'); + } + if (model.model_picker_enabled === false || model.policy?.state === 'disabled') { + continue; + } + models.push({ + id: model.id, + name: model.name, + provider: 'copilot', + maxContextWindow: model.capabilities?.limits?.max_context_window_tokens, + maxPromptTokens: model.capabilities?.limits?.max_prompt_tokens, + maxOutputTokens: model.capabilities?.limits?.max_output_tokens, + supportsVision: model.capabilities?.supports?.vision, + _meta: createPricingMetaFromBilling(normalizeCAPIBilling(model.billing), model.model_picker_price_category, model.model_picker_category), + ...(efforts?.length ? { + configSchema: { + type: 'object', + properties: { + reasoningEffort: { + type: 'string', + title: localize('cloudSandbox.reasoningEffort', "Reasoning Effort"), + enum: [...efforts], + }, + }, + }, + } : {}), + }); + } + return { models, defaultModel: catalog.default_model }; + } + private async _readJson(context: IRequestContext): Promise { const body = await asText(context); if (!body) { diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.ts new file mode 100644 index 000000000000..f2374e4109b0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { cancelOnDispose, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { isCancellationError, onUnexpectedError } from '../../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { ICloudSandboxApiService, ICloudSandboxModelCatalog } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../common/languageModels.js'; +import { AgentHostLanguageModelProvider } from '../agentSessions/agentHost/agentHostLanguageModelProvider.js'; +import { ChatAgentLocation } from '../../common/constants.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; + +export const ICloudSandboxModelCatalogService = createDecorator('cloudSandboxModelCatalogService'); + +export interface ICloudSandboxModelCatalogService { + readonly _serviceBrand: undefined; + readonly onDidChange: Event; + readonly catalog: ICloudSandboxModelCatalog | undefined; + load(retry?: boolean): void; +} + +/** Shares account-scoped discovery and retry notifications across all sandbox model providers. */ +export class CloudSandboxModelCatalogService extends Disposable implements ICloudSandboxModelCatalogService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + private readonly _token = cancelOnDispose(this._store); + private readonly _requestCancellation = this._register(new MutableDisposable()); + private readonly _notification = this._register(new MutableDisposable()); + private _request: Promise | undefined; + private _requested = false; + private _generation = 0; + private _catalog: ICloudSandboxModelCatalog | undefined; + get catalog(): ICloudSandboxModelCatalog | undefined { return this._catalog; } + + constructor( + @ICloudSandboxApiService private readonly _api: ICloudSandboxApiService, + @ILogService private readonly _logService: ILogService, + @INotificationService private readonly _notificationService: INotificationService, + ) { + super(); + this._register(this._api.onDidChangeAccount(() => { + this._generation++; + this._requestCancellation.value?.cancel(); + this._requestCancellation.clear(); + this._notification.clear(); + this._request = undefined; + this._catalog = undefined; + this._onDidChange.fire(); + if (this._requested) { + this.load(true); + } + })); + } + + load(retry = false): void { + if (this._request || (this._requested && !retry)) { + return; + } + this._requested = true; + this._notification.clear(); + const generation = this._generation; + const cancellation = new CancellationTokenSource(this._token); + this._requestCancellation.value = cancellation; + this._request = (async () => { + try { + const catalog = await this._api.listModels(cancellation.token); + if (this._store.isDisposed || generation !== this._generation) { + return; + } + this._catalog = catalog; + this._onDidChange.fire(); + } catch (error) { + if (isCancellationError(error) || this._store.isDisposed || generation !== this._generation) { + return; + } + this._logService.error('[CloudSandboxModels] Failed to load cloud models', error); + const notification = this._notificationService.prompt(Severity.Error, localize('cloudSandbox.modelsFailed', "Could not load models for GitHub sandboxes."), [{ + label: localize('cloudSandbox.retryModels', "Retry"), + run: () => this.load(true), + }]); + this._notification.value = toDisposable(() => notification.close()); + } finally { + if (generation === this._generation) { + this._request = undefined; + this._requestCancellation.clear(); + } + } + })(); + } +} + +/** Adapts the shared cloud catalog to one draft or connected host's model identifier space. */ +export class CloudSandboxModels extends Disposable { + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + private readonly _token = cancelOnDispose(this._store); + private readonly _provider: AgentHostLanguageModelProvider; + private _generation = 0; + models: readonly ILanguageModelChatMetadataAndIdentifier[] = []; + ready = false; + + constructor( + sessionType: string, + vendor: string, + modelProvider: AgentHostLanguageModelProvider | undefined, + @ICloudSandboxModelCatalogService private readonly _catalogService: ICloudSandboxModelCatalogService, + @ILanguageModelsService languageModelsService: ILanguageModelsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + if (modelProvider) { + this._provider = modelProvider; + this._register(toDisposable(() => modelProvider.updateAdditionalModels([]))); + } else { + const descriptor = { vendor, displayName: localize('cloudSandbox.modelsProvider', "GitHub Sandboxes"), configuration: undefined, managementCommand: undefined, when: undefined }; + languageModelsService.deltaLanguageModelChatProviderDescriptors([descriptor], []); + this._register(toDisposable(() => languageModelsService.deltaLanguageModelChatProviderDescriptors([], [descriptor]))); + this._provider = this._register(instantiationService.createInstance(AgentHostLanguageModelProvider, sessionType, vendor)); + this._register(languageModelsService.registerLanguageModelProvider(vendor, this._provider)); + } + this._register(this._catalogService.onDidChange(() => { + void this._updateCatalog().catch(onUnexpectedError); + })); + void this._updateCatalog().catch(onUnexpectedError); + } + + load(retry = false): void { + this._catalogService.load(retry); + } + + private async _updateCatalog(): Promise { + const generation = ++this._generation; + const catalog = this._catalogService.catalog; + if (!catalog) { + this.models = []; + this.ready = false; + this._provider.updateAdditionalModels([]); + this._onDidChange.fire(); + return; + } + this._provider.updateAdditionalModels(catalog.models); + const models = await this._provider.provideLanguageModelChatInfo({}, this._token); + if (this._store.isDisposed || generation !== this._generation) { + return; + } + this.models = models.map(model => ({ + ...model, + metadata: { ...model.metadata, isDefaultForLocation: { [ChatAgentLocation.Chat]: model.metadata.id === catalog.defaultModel } }, + })); + this.ready = true; + this._onDidChange.fire(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts index 632dab16027b..9026b2637a7b 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts @@ -13,7 +13,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; /** The Mission Control call being reported. A closed set, so it is safe to send verbatim. */ -export type CloudSandboxRequestAction = 'connect' | 'reconnect' | 'getEnvironment' | 'listTasks' | 'getTask' | 'createTask' | 'deleteTask' | 'renameTask' | 'archiveTask' | 'unarchiveTask' | 'getTaskEvents' | 'getRepository'; +export type CloudSandboxRequestAction = 'connect' | 'reconnect' | 'getEnvironment' | 'listTasks' | 'getTask' | 'createTask' | 'deleteTask' | 'renameTask' | 'archiveTask' | 'unarchiveTask' | 'getTaskEvents' | 'getRepository' | 'listModels'; /** * How a Mission Control request ended, bucketed so a count is meaningful without carrying the @@ -591,7 +591,7 @@ type CloudSandboxRequestsEvent = { }; type CloudSandboxRequestsClassification = { - action: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Which Mission Control call was counted (connect, reconnect, getEnvironment, listTasks or getTask).' }; + action: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Which Mission Control operation was counted, including model discovery, task operations, and environment connections.' }; windowMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds covered by these counts.' }; total: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Requests issued for this action during the window.' }; succeeded: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Requests that returned a success status.' }; diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts index 88189388f177..38e5228c3515 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts @@ -12,6 +12,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { CloudSandboxAgentHostService } from './cloudSandboxAgentHostService.js'; import { CloudSandboxApiService } from './cloudSandboxApiService.js'; +import { CloudSandboxModelCatalogService, ICloudSandboxModelCatalogService } from './cloudSandboxModels.js'; import { CloudSandboxTelemetryService, ICloudSandboxTelemetryService } from './cloudSandboxTelemetry.js'; import { EditorCloudSandboxContribution } from './editorCloudSandboxContribution.js'; import { RemoteAgentHostContribution } from './remoteAgentHostChatContribution.js'; @@ -19,6 +20,7 @@ import { IRemoteAgentHostConnectionCustomizationService, RemoteAgentHostConnecti registerSingleton(ICloudSandboxTelemetryService, CloudSandboxTelemetryService, InstantiationType.Delayed); registerSingleton(ICloudSandboxApiService, CloudSandboxApiService, InstantiationType.Delayed); +registerSingleton(ICloudSandboxModelCatalogService, CloudSandboxModelCatalogService, InstantiationType.Delayed); registerSingleton(ICloudSandboxAgentHostService, CloudSandboxAgentHostService, InstantiationType.Delayed); registerSingleton(IRemoteAgentHostConnectionCustomizationService, RemoteAgentHostConnectionCustomizationService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts index f59a7302cea2..af31b3bc6773 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts @@ -44,6 +44,8 @@ import { IWorkbenchEnvironmentService } from '../../../../services/environment/c import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { isCloudSandboxConnectionAddress } from './cloudSandboxConnectionCustomization.js'; import { IRemoteAgentHostAuthenticationService } from './remoteAgentHostAuthentication.js'; +import { CloudSandboxModels } from './cloudSandboxModels.js'; +import { CLOUD_SANDBOX_AGENT_PROVIDER } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; Registry.as(ChatSessionsExtensions.AsyncActivation).register({ matchSessionType: sessionType => isRemoteAgentHostSessionType(sessionType), @@ -460,6 +462,11 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc agentStore.add(toDisposable(() => connState.modelProviders.delete(agent.provider))); agentStore.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); modelProvider.updateModels(agent.models); + if (isCloudSandboxConnectionAddress(address) && agent.provider === CLOUD_SANDBOX_AGENT_PROVIDER) { + // Sandbox runtimes can accept cloud-service models omitted from their AHP catalog. + const cloudModels = agentStore.add(this._instantiationService.createInstance(CloudSandboxModels, sessionType, vendor, modelProvider)); + cloudModels.load(); + } this._logService.info(`[RemoteAgentHost] Registered agent ${agent.provider} from ${address} as ${sessionType}`); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts index be22179533eb..ccca5b82b369 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts @@ -9,7 +9,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { PolicyState, SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; @@ -24,6 +24,29 @@ suite('AgentHostLanguageModelProvider', () => { return store.add(new AgentHostLanguageModelProvider('agent-host-copilotcli', 'copilotcli')); } + test('retains cloud-service models omitted from the host catalog across root updates', async () => { + const provider = createProvider(); + provider.updateAdditionalModels([ + { ...makeModel('claude-sonnet-4.6'), configSchema: { type: 'object', properties: { reasoningEffort: { type: 'string', title: 'Effort', enum: ['low', 'high'] } } } }, + makeModel('disabled'), + ]); + provider.updateModels([makeModel('auto'), { ...makeModel('disabled'), policyState: PolicyState.Disabled }]); + const first = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + provider.updateModels([makeModel('auto'), makeModel('host-only'), { ...makeModel('disabled'), policyState: PolicyState.Disabled }]); + const updated = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + provider.updateAdditionalModels([]); + const cleared = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + assert.deepStrictEqual({ + first: first.map(model => model.metadata.id), + efforts: first[1].metadata.configurationSchema?.properties?.reasoningEffort.enum, + updated: updated.map(model => model.metadata.id), + cleared: cleared.map(model => model.metadata.id), + }, { + first: ['auto', 'claude-sonnet-4.6'], efforts: ['low', 'high'], + updated: ['auto', 'host-only', 'claude-sonnet-4.6'], cleared: ['auto', 'host-only'], + }); + }); + test('groups native autoTier without inventing a default or losing fast', async () => { const provider = createProvider(); provider.updateModels([{ diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts index 828ec9989f2f..cfe7c8574cef 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts @@ -253,6 +253,50 @@ suite('CloudSandboxApiService connection credentials', () => { }; } + test('loads cloud models and reasoning metadata without creating a task or environment', async () => { + const requests: { path: string; method: string | undefined; integration: string | string[] | undefined }[] = []; + const { service } = createService(store, { + tasks: [], repositories: new Map(), + onRequest: (url, _token, options) => { + requests.push({ path: url.pathname, method: options.type, integration: options.headers?.['Copilot-Integration-Id'] }); + return jsonResponse({ + default_model: 'auto', + data: [ + { id: 'auto', name: 'Auto' }, + { id: 'brand-new-model', name: 'New Model', capabilities: { supports: { vision: true, reasoning_effort: ['low', 'high', 'new-effort'] }, limits: { max_prompt_tokens: 1000 } } }, + { id: 'disabled', name: 'Disabled', policy: { state: 'disabled' } }, + { id: 'hidden', name: 'Hidden', model_picker_enabled: false }, + ], + }); + }, + }); + const catalog = await service.listModels(CancellationToken.None); + assert.deepStrictEqual({ + requests, + defaultModel: catalog.defaultModel, + models: catalog.models.map(model => ({ id: model.id, vision: model.supportsVision, input: model.maxPromptTokens, efforts: model.configSchema?.properties.reasoningEffort.enum })), + }, { + requests: [{ path: '/agents/swe/models', method: 'GET', integration: COPILOT_INTEGRATION_ID }], + defaultModel: 'auto', + models: [ + { id: 'auto', vision: undefined, input: undefined, efforts: undefined }, + { id: 'brand-new-model', vision: true, input: 1000, efforts: ['low', 'high', 'new-effort'] }, + ], + }); + }); + + for (const body of [{}, { data: [null] }, { data: [{ id: 'bad', name: 'Bad', capabilities: { supports: { reasoning_effort: [1] } } }] }]) { + test(`rejects invalid cloud model metadata: ${JSON.stringify(body)}`, async () => { + const { service } = createService(store, { tasks: [], repositories: new Map(), onRequest: () => jsonResponse(body) }); + await assert.rejects(service.listModels(CancellationToken.None), /invalid model/); + }); + } + + test('reports cloud catalog HTTP failures instead of treating them as an empty catalog', async () => { + const { service } = createService(store, { tasks: [], repositories: new Map(), onRequest: () => jsonResponse({}, 403) }); + await assert.rejects(service.listModels(CancellationToken.None), /model catalog failed: HTTP 403/); + }); + for (const action of ['connect', 'reconnect'] as const) { test(`${action} logs safe upstream correlation for an HTTP failure`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { const logService = new TestLogService(); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxModels.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxModels.test.ts new file mode 100644 index 000000000000..35fae82f25e3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxModels.test.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ICloudSandboxApiService } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService, IPromptChoice, NoOpNotification } from '../../../../../../platform/notification/common/notification.js'; +import { CloudSandboxModelCatalogService, CloudSandboxModels, ICloudSandboxModelCatalogService } from '../../../browser/remoteAgentHost/cloudSandboxModels.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../../base/common/errors.js'; + +suite('CloudSandboxModels', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function setup(listModels: ICloudSandboxApiService['listModels'], modelProvider?: AgentHostLanguageModelProvider) { + const instantiation = store.add(new TestInstantiationService()); + const accounts = store.add(new Emitter()); + const errors: string[] = []; + const retries: IPromptChoice[] = []; + const notified = new DeferredPromise(); + instantiation.stub(ICloudSandboxApiService, { onDidChangeAccount: accounts.event, listModels }); + instantiation.stub(IInstantiationService, instantiation); + instantiation.stub(ILanguageModelsService, { deltaLanguageModelChatProviderDescriptors: () => { }, registerLanguageModelProvider: () => toDisposable(() => { }) }); + instantiation.stub(ILogService, new NullLogService()); + instantiation.stub(INotificationService, { + prompt: (_severity, message, choices) => { + errors.push(String(message)); + retries.push(...choices); + notified.complete(); + return new NoOpNotification(); + }, + }); + const sharedCatalog = store.add(instantiation.createInstance(CloudSandboxModelCatalogService)); + instantiation.stub(ICloudSandboxModelCatalogService, sharedCatalog); + const createCatalog = (vendor = 'sandbox-catalog', provider = modelProvider) => store.add(instantiation.createInstance(CloudSandboxModels, 'sandbox', vendor, provider)); + const catalog = createCatalog(); + return { catalog, sharedCatalog, createCatalog, accounts, errors, retries, notified }; + } + + test('supplements the connected host catalog without dropping its models on account changes', async () => { + const provider = store.add(new AgentHostLanguageModelProvider('sandbox', 'sandbox')); + provider.updateModels([{ id: 'host-model', name: 'Host Model', provider: 'copilot' }]); + const { catalog, accounts } = setup(async () => ({ models: [{ id: 'claude-sonnet-4.6', name: 'Sonnet 4.6', provider: 'copilot' }] }), provider); + const ready = Event.toPromise(Event.filter(catalog.onDidChange, () => catalog.ready)); + catalog.load(); + await ready; + const loaded = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + const refreshed = Event.toPromise(Event.filter(catalog.onDidChange, () => catalog.ready)); + accounts.fire('changed'); + const duringRefresh = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + await refreshed; + catalog.dispose(); + const afterDispose = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + assert.deepStrictEqual({ + loaded: loaded.map(model => model.metadata.id), duringRefresh: duringRefresh.map(model => model.metadata.id), afterDispose: afterDispose.map(model => model.metadata.id), + }, { loaded: ['host-model', 'claude-sonnet-4.6'], duringRefresh: ['host-model'], afterDispose: ['host-model'] }); + }); + + test('coalesces reads and caches the catalog until the account changes', async () => { + let calls = 0; + const { catalog, createCatalog, accounts } = setup(async () => ({ + defaultModel: 'new-model', + models: [{ id: `model-${++calls}`, provider: 'copilot', name: 'New Model', configSchema: { type: 'object', properties: { reasoningEffort: { type: 'string', title: 'Effort', enum: ['low', 'high'] } } } }], + })); + const secondCatalog = createCatalog('second'); + const thirdCatalog = createCatalog('third'); + const consumers = [catalog, secondCatalog, thirdCatalog]; + const first = Promise.all(consumers.map(consumer => Event.toPromise(Event.filter(consumer.onDidChange, () => consumer.ready)))); + catalog.load(); + secondCatalog.load(); + thirdCatalog.load(); + await first; + const cachedCatalog = createCatalog('cached'); + const cached = Event.toPromise(Event.filter(cachedCatalog.onDidChange, () => cachedCatalog.ready)); + cachedCatalog.load(); + await cached; + consumers.push(cachedCatalog); + const before = consumers.map(consumer => consumer.models.map(model => model.metadata.id)); + const second = Promise.all(consumers.map(consumer => Event.toPromise(Event.filter(consumer.onDidChange, () => consumer.ready)))); + accounts.fire('another-account'); + await second; + assert.deepStrictEqual({ calls, before, after: consumers.map(consumer => consumer.models.map(model => model.metadata.id)), efforts: catalog.models[0].metadata.configurationSchema?.properties?.reasoningEffort.enum }, { + calls: 2, before: [['model-1'], ['model-1'], ['model-1'], ['model-1']], after: [['model-2'], ['model-2'], ['model-2'], ['model-2']], efforts: ['low', 'high'], + }); + }); + + test('offers an explicit retry after a failed request', async () => { + let calls = 0; + const { catalog, createCatalog, errors, retries, notified } = setup(async () => { + if (++calls === 1) { + throw new Error('catalog unavailable'); + } + return { models: [{ id: 'model', provider: 'copilot', name: 'Model' }] }; + }); + const otherCatalog = createCatalog('other'); + catalog.load(); + otherCatalog.load(); + await notified.p; + const lateCatalog = createCatalog('late'); + lateCatalog.load(); + const consumers = [catalog, otherCatalog, lateCatalog]; + const ready = Promise.all(consumers.map(consumer => Event.toPromise(Event.filter(consumer.onDidChange, () => consumer.ready)))); + await retries[0].run(); + await ready; + assert.deepStrictEqual({ calls, errors, ready: consumers.map(consumer => consumer.ready), ids: consumers.map(consumer => consumer.models.map(model => model.metadata.id)) }, { + calls: 2, errors: ['Could not load models for GitHub sandboxes.'], ready: [true, true, true], ids: [['model'], ['model'], ['model']], + }); + }); + + test('cancels an in-flight catalog request when its owner is disposed', async () => { + const cancelled = new DeferredPromise(); + const { catalog, sharedCatalog, errors } = setup(async token => { + store.add(token.onCancellationRequested(() => cancelled.complete())); + await cancelled.p; + throw new CancellationError(); + }); + catalog.load(); + sharedCatalog.dispose(); + await cancelled.p; + assert.deepStrictEqual(errors, []); + }); + + test('disposing one provider does not cancel discovery needed by another', async () => { + const response = new DeferredPromise>>(); + let cancelled = false; + let calls = 0; + const { catalog, createCatalog } = setup(token => { + calls++; + store.add(token.onCancellationRequested(() => cancelled = true)); + return response.p; + }); + const otherCatalog = createCatalog('other'); + const ready = Event.toPromise(Event.filter(otherCatalog.onDidChange, () => otherCatalog.ready)); + catalog.load(); + otherCatalog.load(); + catalog.dispose(); + await response.complete({ models: [{ id: 'model', name: 'Model', provider: 'copilot' }] }); + await ready; + assert.deepStrictEqual({ calls, cancelled, ids: otherCatalog.models.map(model => model.metadata.id) }, { calls: 1, cancelled: false, ids: ['model'] }); + }); + + test('ignores a late response from the previous account', async () => { + const old = new DeferredPromise>>(); + let calls = 0; + const { catalog, accounts } = setup(() => ++calls === 1 ? old.p : Promise.resolve({ models: [{ id: 'current', provider: 'copilot', name: 'Current' }] })); + catalog.load(); + const ready = Event.toPromise(Event.filter(catalog.onDidChange, () => catalog.ready)); + accounts.fire('current-account'); + await ready; + await old.complete({ models: [{ id: 'old', provider: 'copilot', name: 'Old' }] }); + assert.deepStrictEqual(catalog.models.map(model => model.metadata.id), ['current']); + }); +}); From 9592504cbba66003ba4871f5f9514cc456729b7b Mon Sep 17 00:00:00 2001 From: Robo Date: Mon, 5 Oct 2026 13:39:50 +0900 Subject: [PATCH 36/49] chore: bump electron@43.7.7 (#339634) * chore: bump electron@43.7.7 * chore: bump distro --- .npmrc | 4 +- build/checksums/electron.txt | 150 +++++++++++++++++------------------ cgmanifest.json | 6 +- package.json | 2 +- 4 files changed, 81 insertions(+), 81 deletions(-) diff --git a/.npmrc b/.npmrc index 89d0f0c309ff..7a097f359bcd 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="43.7.5" -ms_build_id="15523606" +target="43.7.7" +ms_build_id="15553055" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 2ec89bcc11d1..ca7700e86c69 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -7ca9d9e9654f66e63d3117b868f5407605af53ca6115bcf9f0490a68568afc14 *chromedriver-v43.7.5-darwin-arm64.zip -0133b3e40b192e9ff3fa1e80773a25e591b8638a95aa50e33b773a6222c12493 *chromedriver-v43.7.5-darwin-x64.zip -f85f88fe6cf1be1e55312c1302d1f3d7d0f319dfe4e7e610f9fbf3f0b682018f *chromedriver-v43.7.5-linux-arm64.zip -97f4939ba6d58954673cbda3c14ff5c0b00e5a4b7e70a1b166d0313da91a8a5f *chromedriver-v43.7.5-linux-armv7l.zip -854d5396d3abfb91284a9e66e6cb962a7e274aad28cd86ff6d5c22492bfc06d7 *chromedriver-v43.7.5-linux-x64.zip -fd1cc7a02fc96fabb01482a87807d9e6dd7fd70df5b97be5e5704422d751d063 *chromedriver-v43.7.5-mas-arm64.zip -713387926080fab188c99674548653a144f4e3a0890924f60b81e99b2fff33b7 *chromedriver-v43.7.5-mas-x64.zip -423f1210316104b5daaf5ffee8b7e33c9f537e52b38b8f75d3bc1b9021ea47bd *chromedriver-v43.7.5-win32-arm64.zip -b868501754d37212d561bc5298caf99d38e9c6d9eef8902263a6f6d3eab5e569 *chromedriver-v43.7.5-win32-ia32.zip -9f5d50e29f3cfb13b8afd5cc711ae3219cdd76d52d2dcc86991221d2b7342a39 *chromedriver-v43.7.5-win32-x64.zip -c94a7f38ff9c34b6ef2d4861ac74b6015ce93b92814721ae1e0e7fc7ef75bc6c *electron-api.json -9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-darwin-arm64-dsym-snapshot.zip -6bf01e64c0aa457c7725ddaa8abaec50a7e06a3d2f325795934e3a6370f83db7 *electron-v43.7.5-darwin-arm64-dsym.tar.xz -9486b06f8aa6b20b082e0fa8741141a463de96b25a2b362a81f192d1a823c996 *electron-v43.7.5-darwin-arm64-symbols.zip -4ddb8717f3c9ade6c5b8a86f0f2b9648067108de93f9a1e7fd6a9ffcf06692ac *electron-v43.7.5-darwin-arm64.zip -9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-darwin-x64-dsym-snapshot.zip -25c9e7e63ce1b904a2bc7a49f4d1845b4ab4fb461f84a4520d1ec78c72c3d37a *electron-v43.7.5-darwin-x64-dsym.tar.xz -9cd6fc5cf09edfed1ba29cf6b72ce929dda71aecc584f9823a93196bcda25ed9 *electron-v43.7.5-darwin-x64-symbols.zip -fec7945208ade18b15c9f386c29bdf973f3cb7f779df9bc5e1a04f9de8a146ce *electron-v43.7.5-darwin-x64.zip -ec18f1ed85958b6bb0e7285a15fba7da3f546c5919e477a108150f86936e3988 *electron-v43.7.5-linux-arm64-debug.zip -5893561c6452225dd6dc8eb8bde572e6d675b5cdcdbba6e0d3c9ad11a95df564 *electron-v43.7.5-linux-arm64-symbols.zip -0e8017c56a243deed2510467e5de4be17a2c0634eb017429851ad026551e28ec *electron-v43.7.5-linux-arm64.zip -5224ccd7679443680c5a51f9ccaf2ede12b940a39191261dd9011eec6d0231d3 *electron-v43.7.5-linux-armv7l-debug.zip -0f8d9b40fde9a6dbc211f99a3caae6f0367afb636e1770f985eaba5993598cf3 *electron-v43.7.5-linux-armv7l-symbols.zip -1079cbaf38af752402c29e1a888a9f044403baf784693de3147c7c29097a4157 *electron-v43.7.5-linux-armv7l.zip -6ed81092b68add4ec6fb375615f14bdb4a6f5c598242f7800431c7b4d11f7645 *electron-v43.7.5-linux-x64-debug.zip -829e3e9c97c08cc61126c43885798325ce1feffcfdb0c882c5489db46a9fb079 *electron-v43.7.5-linux-x64-symbols.zip -dce41d6d9a36a2854c5fa1a97f90bc7a8ac8285cf2a76fe3f997c7a02432b364 *electron-v43.7.5-linux-x64.zip -9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-mas-arm64-dsym-snapshot.zip -abf2db07595e11531aa2cb4c572b6d5fbe81705878b382d962d00b2262f810c7 *electron-v43.7.5-mas-arm64-dsym.tar.xz -8e2cde372c0a5f5245c334343741a94ad840e86a698f1ec943f259e39ba3f552 *electron-v43.7.5-mas-arm64-symbols.zip -bebd05a60252b9a4f6c83202e3a5b435ef8a489ff6de110fa76aa52a7903dd3c *electron-v43.7.5-mas-arm64.zip -9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-mas-x64-dsym-snapshot.zip -1bbd11786d0ef8b3ba36c49e6831f4948f6fddc6a1bba1696aa2dce6724c6d8d *electron-v43.7.5-mas-x64-dsym.tar.xz -7c6c3971bcc89d4caa4a6f16a7244aac7f3ae300e86fe65820347f5e46987f9f *electron-v43.7.5-mas-x64-symbols.zip -5502585a5c698e92a5472b8fbeeaf6aa757f70d5f1a42f5592dff889c6b8ee37 *electron-v43.7.5-mas-x64.zip -012133e592664f7b1fd74a42b2f4b9e0e14ec5510ec65a079cd80d8add2eda5e *electron-v43.7.5-win32-arm64-pdb.zip -245111a9bbf1f1acf99a4c0212a7cf1226eea5cb4bcf20326802e9b5b50e2e4c *electron-v43.7.5-win32-arm64-symbols.zip -90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.5-win32-arm64-toolchain-profile.zip -996f250e86611b2e5245b8ac09a0375f24d6e343292799a6c9bf7334391fd9c9 *electron-v43.7.5-win32-arm64.zip -cfd279ac0110c4b128627eaffed83e0d3dc3b70d6d8d6a54ca5254bef5a79d15 *electron-v43.7.5-win32-ia32-pdb.zip -1b52b17efc00fab5317d191e0b871fd8e324983dcddfcc6829c867e1baf5dc9f *electron-v43.7.5-win32-ia32-symbols.zip -90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.5-win32-ia32-toolchain-profile.zip -86897584b0b36a7a55280a33bcd2040115d3651b7ea753f23ab5097286947482 *electron-v43.7.5-win32-ia32.zip -c7531d071950a50b3d8b55ec403bdcbfdd9a8ba62f91a18a88de7a638084525a *electron-v43.7.5-win32-x64-pdb.zip -1891dfd0d60dc76bbf83601a354c866642ce1fdf5b9a2063666990fa99424799 *electron-v43.7.5-win32-x64-symbols.zip -90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.5-win32-x64-toolchain-profile.zip -7acfa0646793f912ff983c8db8c3a145dc18ee40fe3d11a01840fd59cb76e5a2 *electron-v43.7.5-win32-x64.zip -81e05df0600cb229dbdb080e5200ddf3532d0c4b5a668e2cba5bc5683f805098 *electron.d.ts -c3f967e6e0fcada1d2dd24b08f190b02aaf243a014531901e82b70417193c48b *ffmpeg-v43.7.5-darwin-arm64.zip -a6860ea4f0ec3954ae2686ae2ff2fd72cb4793f7229fe0d0c9c8efd0487e7297 *ffmpeg-v43.7.5-darwin-x64.zip -ed1d9f6af263524c00ff955f50bf289e19c881625384ff421c773ef2aeb8a82d *ffmpeg-v43.7.5-linux-arm64.zip -001847bd19549781850fd6f69941a54f92356ed26bca02892e9642637215668d *ffmpeg-v43.7.5-linux-armv7l.zip -6638429b92e6d213b78114923585526aff88dbe25f91ace7f2bd5228fac08de5 *ffmpeg-v43.7.5-linux-x64.zip -5290184f135527f4b58636e14869688a9fad6249cfe9f3608c9046e06efc6660 *ffmpeg-v43.7.5-mas-arm64.zip -785ededd318692376a61ed854a95fe9103ad2f7616f64b5e3b96ce34eaecdad1 *ffmpeg-v43.7.5-mas-x64.zip -1bbd64356da38ad2c8b9dd254046b0491e07d72e27952ee7f49331191a26bdb1 *ffmpeg-v43.7.5-win32-arm64.zip -7e9044d58cf7695e5549f73a4d0a54a7b2e24af930ff69195765c890346361f2 *ffmpeg-v43.7.5-win32-ia32.zip -b1b2597a18e68c70d0f65c46cdd0f0e10cb847e227e3e34cdbe6a6eee0e663c0 *ffmpeg-v43.7.5-win32-x64.zip -47b695db7f9dedebbdeec47070622da4312574f38e8ca8d77ef94c827a522a2f *hunspell_dictionaries.zip -198993602d37429e707e494f781eea876de3e9fd85d016c01b1f7432a747b418 *libcxx-objects-v43.7.5-linux-arm64.zip -80b6639adb398b42622ec0644a3a9c9e3f696309825537a817d42cf92c0a4f2e *libcxx-objects-v43.7.5-linux-armv7l.zip -dcb37cf5b0b1941e0304379dfea3587a3ba39167d8edca9e2ab749f54349586d *libcxx-objects-v43.7.5-linux-x64.zip -88eeecbe3a86626bc8b9677d764e831b13589449126b8b5aa84cb291e38f220e *libcxx_headers.zip -88a69e434f5a001028891091b32535b7b4e95c7764e4a7597112467042615d9b *libcxxabi_headers.zip -cdf1bb3bfa3c2a95ad463f9abd0cb3c94f39b9e5eb317eced9715d6496829c34 *mksnapshot-v43.7.5-darwin-arm64.zip -08e2460962d8729e8fa36bf30758d943ef4059f99e2d41dfd329193b51f94960 *mksnapshot-v43.7.5-darwin-x64.zip -d4544b339553ba5722aeaa53c22345183ae6d041e6330de0b0f3ef21d9af2046 *mksnapshot-v43.7.5-linux-arm64-x64.zip -ab060e6272e2e536732921eccef686560268b02edf832d63c3cada562cb423e7 *mksnapshot-v43.7.5-linux-armv7l-x64.zip -1a271d78fbb4922c02043538d53a4f87fe4e1c8bd58e7a78a29aeffb2cb742f2 *mksnapshot-v43.7.5-linux-x64.zip -42e489bd90f30581941143a2285861c69316bd3a603320bbe32ef217b8650adc *mksnapshot-v43.7.5-mas-arm64.zip -c2082d5d7f90e866760d2a6424bab260b3c757cc00884e1f00d87cad6d53a453 *mksnapshot-v43.7.5-mas-x64.zip -5665055067d7f53d459f0c63884648eb7dcfbf64b417b8c0b0738b7133b97812 *mksnapshot-v43.7.5-win32-arm64-x64.zip -7c882bd39f45cc9e1112f7b949b443305e959fad57292a9f9b97c3e45d502ce8 *mksnapshot-v43.7.5-win32-ia32.zip -bff2442315ad1482db15f6230f7f98308a0b758ab46b0b6f53ff0ace715e96ce *mksnapshot-v43.7.5-win32-x64.zip +b9b7d9ff132e9655a574f03edfb01add8d43a8b0d3a79320c226f6aa99c922ea *chromedriver-v43.7.7-darwin-arm64.zip +f3b26877642739e6c668e6368daada33bb1dfcf4d5fec34143195e184e984569 *chromedriver-v43.7.7-darwin-x64.zip +091707f83e4191f36848152d664c80c255572dfe36a042250ca94037c06959d2 *chromedriver-v43.7.7-linux-arm64.zip +9c3dc509c12709522a22e3c4eb3270907050391fe05d89f68ab5ea689f9aa5b2 *chromedriver-v43.7.7-linux-armv7l.zip +7ea6157ec70b065ef6a7f77fc1822dc6441f5702b0f99660e26cfca1897ecfa4 *chromedriver-v43.7.7-linux-x64.zip +cad78ce6d3399821fde0cc5701109334409aace9b2c82ab8f4c0d3fffa152906 *chromedriver-v43.7.7-mas-arm64.zip +42221fd4d828376802e644178717f1b0626bd1188d45199e958e17ab520ff9ee *chromedriver-v43.7.7-mas-x64.zip +d2c67dad42cfdcf17fce6eac8bba81502c4b2d9cd90cc16f189b1da71dde8ca3 *chromedriver-v43.7.7-win32-arm64.zip +79aedd7837ac32ec19f98677901def62d5cdbbe63d90fe5f25ca131f8609a843 *chromedriver-v43.7.7-win32-ia32.zip +8b81ca5a4add14593548f5b4042a028e4c50758d61c5a244741ca6d07be3c95d *chromedriver-v43.7.7-win32-x64.zip +aabd65bdb7a4fbd80c114af429392aa5a126382883c7c2e8f3ee11acf2e4ce08 *electron-api.json +649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-darwin-arm64-dsym-snapshot.zip +941f6adb5eb6dbce25db81158a8f19fd5b4b24bcc97ea5986bb901f822b93a54 *electron-v43.7.7-darwin-arm64-dsym.tar.xz +34f7acfef1ff41f81b63895f010b866c1f39ac22e0d700445eb6e9ee595276c4 *electron-v43.7.7-darwin-arm64-symbols.zip +9327d8ba5bc9e279d1a2f7da90235301c65a2e80eb4ad3bc5610d28d483340f9 *electron-v43.7.7-darwin-arm64.zip +649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-darwin-x64-dsym-snapshot.zip +2a0fc941390038cad45ef580cf65ee333f61f6e4504b9433447ed8fa3585e234 *electron-v43.7.7-darwin-x64-dsym.tar.xz +6f003f93738843419063adba7dbac56ac1dcfc79dcbeee39d700c335d34d831a *electron-v43.7.7-darwin-x64-symbols.zip +cbed66567d55db4a2bffad0bb6ee9795ca0037ad241039f7473fe75680d10905 *electron-v43.7.7-darwin-x64.zip +ba871fb65de9fbfd71f9aaafc1ac278434f497ee921e9f27fee9e0a5000e9eef *electron-v43.7.7-linux-arm64-debug.zip +d13b1f995845a5a138c35100944dc1264b98be3dd5540253640a66fc388d233d *electron-v43.7.7-linux-arm64-symbols.zip +16071038a9677d0f00b11d3d0f0b7b4ea3250987727aa73aa563b87789f2d5ab *electron-v43.7.7-linux-arm64.zip +bb2295977fc6d33adce9f62def5dfaa19de0e1bd29e5bbd366e0cc35d1a57a09 *electron-v43.7.7-linux-armv7l-debug.zip +07c0739157f9674827c7d55976016972cff70e0876d68287bdf24fca54d778bd *electron-v43.7.7-linux-armv7l-symbols.zip +088c2817c1b5928c5bea706cc60aa05ea328e6d1d8bd549fda75388a3552482a *electron-v43.7.7-linux-armv7l.zip +0f20d65a6f671b1966de12d3f71c2d96a1a65e7ba8c1d534b60bc08c7f82b950 *electron-v43.7.7-linux-x64-debug.zip +a5fb797b973514622f37154d5088132705ce86641f05e6b1e858269a86961868 *electron-v43.7.7-linux-x64-symbols.zip +4d0a48398c444258dbcf2f5f83b49ca5bc53583130f354e0c299dad5b22b5271 *electron-v43.7.7-linux-x64.zip +649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-mas-arm64-dsym-snapshot.zip +dc850f562fbf669db1d9940d458c7ba9790392cff18d95ebaec1fe94a8d583ff *electron-v43.7.7-mas-arm64-dsym.tar.xz +61cf1a0dc10498aff1d1eefa9a5b96350cba32734bdc9f7f40724f9aa17d03d9 *electron-v43.7.7-mas-arm64-symbols.zip +987eebfdc29638b9066e5484b0dcb59e7096627edebad722c7649f8472f2c8a0 *electron-v43.7.7-mas-arm64.zip +649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-mas-x64-dsym-snapshot.zip +8caa427ef3e1dd3f1832a3b782ace268999afcf011540fe05a62c957917487ea *electron-v43.7.7-mas-x64-dsym.tar.xz +91f67bf1b2deedf84355d98095a6060de69fb64c01caf223a35e0edef38aee55 *electron-v43.7.7-mas-x64-symbols.zip +f5eaad26bda0bc133f8d0af0f3a77d2e16adf2572306bec611424c3bab876acf *electron-v43.7.7-mas-x64.zip +b7aa250996ba4aadcbb115e951c6a46f19f246ee6178cffc6b115088189fc1d5 *electron-v43.7.7-win32-arm64-pdb.zip +e63955e73f754a249db46a2c312fd904b6405018650d22b8a16037a9c10270e0 *electron-v43.7.7-win32-arm64-symbols.zip +90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.7-win32-arm64-toolchain-profile.zip +d3751b5046350eee0cfd88e7709697c0ef05db93bb3435195c2d0cec50f0ad1f *electron-v43.7.7-win32-arm64.zip +2e8d41b27d3c2394bfeb4ec00904d7049c8a7ccaa105e678179a983cdfb319db *electron-v43.7.7-win32-ia32-pdb.zip +67ea48dcfdcca2f7b854fbe3689d56fe047ca9a2aee0808422ef313e0bc4b52a *electron-v43.7.7-win32-ia32-symbols.zip +90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.7-win32-ia32-toolchain-profile.zip +a170eeedf4a216b3b0672b151294e7aef0b4fdc15ae5c207ea3f9f37c4a14c42 *electron-v43.7.7-win32-ia32.zip +3b65b14be1594bba502283ae02e9af54b55b1894f3f6be961c608c9e80a74179 *electron-v43.7.7-win32-x64-pdb.zip +37d27ae6956d20fde7fa1218453ad9514c106f17cf30634c452302a7453f189f *electron-v43.7.7-win32-x64-symbols.zip +90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.7-win32-x64-toolchain-profile.zip +97dcb75065444ef031b9b6ea814ccd2109b97934fffb0c503a555d4737ca79cc *electron-v43.7.7-win32-x64.zip +d78a11ef458b68672c35957f0252396a1fa19e73a8ca3b9178e2e75cd090453d *electron.d.ts +9cd0907e7c7c652cc6f519c8e7d75cee26bed06394bb1d51fabdb9d3ada33c67 *ffmpeg-v43.7.7-darwin-arm64.zip +fc2ecfa9bb7ab0131e073f138923ee58f01b88941e366a0be1bad821ed5386bd *ffmpeg-v43.7.7-darwin-x64.zip +ed1d9f6af263524c00ff955f50bf289e19c881625384ff421c773ef2aeb8a82d *ffmpeg-v43.7.7-linux-arm64.zip +001847bd19549781850fd6f69941a54f92356ed26bca02892e9642637215668d *ffmpeg-v43.7.7-linux-armv7l.zip +6638429b92e6d213b78114923585526aff88dbe25f91ace7f2bd5228fac08de5 *ffmpeg-v43.7.7-linux-x64.zip +cbbd298f4548e53200d6b0321bea708963e94acc699059721642183301752109 *ffmpeg-v43.7.7-mas-arm64.zip +7943078d8e3ee924f1cad1354a29aa3aceaf0b7b7414df3a71924ea9c8906223 *ffmpeg-v43.7.7-mas-x64.zip +c8ec52d6bbda2789c678211a76ffc6eaf1dc5faebe1269fc4d6aafa7fbc5510f *ffmpeg-v43.7.7-win32-arm64.zip +37f7bf4b42615e880f971d8714f2b1f4629c06bd8bbd73c8993a8713f8d3628f *ffmpeg-v43.7.7-win32-ia32.zip +df14da964d36f36c50030004bb5e93a3b5fac9e4ddd6dd4f0e4ecdd49456ee4f *ffmpeg-v43.7.7-win32-x64.zip +1987f0c54ac21514090440f4dc981245cae34b71e95ddbeffd5bcb5da9a4982e *hunspell_dictionaries.zip +cd2a8fc7973a60a60f1612d87528e07eb047fad61106f240f4e39bffe9b66969 *libcxx-objects-v43.7.7-linux-arm64.zip +76dc80ad01bf2f0da658d5e58e256bcbcfc5b5e03d5ce25c3a43b9073d0e2a4f *libcxx-objects-v43.7.7-linux-armv7l.zip +5df32a0f3b4e11575d619dfc30bd614238a616f87303142b37bcf62b6206047f *libcxx-objects-v43.7.7-linux-x64.zip +f318b7480e1e9b6149cfa83dedee6027a381f4dc7a4b99af89dddbab4f6d0261 *libcxx_headers.zip +600988a1000006cb10d8b88727c6877dfe3e0cbcb49baee317551d8131593c0c *libcxxabi_headers.zip +080d43c0521b53dc9fffe91b17f9592518230aa0c819bac3e7a6aff484126895 *mksnapshot-v43.7.7-darwin-arm64.zip +3a4cf3ca11eb4f98e66e9ef111f7be82d5d517851ef0882d71bf25ef3f5e4adb *mksnapshot-v43.7.7-darwin-x64.zip +052aa030567fe51301f4ba7c52098583868296f0ea91fa1a78158f763bec1332 *mksnapshot-v43.7.7-linux-arm64-x64.zip +de81d082e286d93e6c2baa6a2ce4b5ccf36dbf5fab512c1a8274e4ad1d6ba660 *mksnapshot-v43.7.7-linux-armv7l-x64.zip +1a222e37532fb300237eedfc6eae2a836f9b8ccf988af64507c88178a9ad3a41 *mksnapshot-v43.7.7-linux-x64.zip +4aaa40fcf9c283f9c3106523fcaff9301dac65b7d36434aa1970def061c82a20 *mksnapshot-v43.7.7-mas-arm64.zip +2a46e79869eaaee734f7f70ef5650bdba3b72cce26a201c8f1a87e15364efd16 *mksnapshot-v43.7.7-mas-x64.zip +8de088472502ed3a26b5a56b016849add2cc694eb2acc76e599d582c675ca8e1 *mksnapshot-v43.7.7-win32-arm64-x64.zip +e73d7a9ada185a6182bdf6872f6c5a7ae22f18a1f7efdb9e4fcb7b21b21b0c53 *mksnapshot-v43.7.7-win32-ia32.zip +4878c2cf511a54a3d38cab480b0f3a2699f84e0d0bdf80b7f300cfbe867ca8b5 *mksnapshot-v43.7.7-win32-x64.zip diff --git a/cgmanifest.json b/cgmanifest.json index a0e0e7e6ae0e..4e86b48bb615 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "5338ed09bfdcf1bbd2e6bab722c2620efe8c4790", - "tag": "43.7.5" + "commitHash": "ddb762f50fbd70384d0385302697e60ada549024", + "tag": "43.7.7" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "43.7.5" + "version": "43.7.7" }, { "component": { diff --git a/package.json b/package.json index b3ed12cc63d1..203e150150f1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.141.0", - "distro": "a620eb1a35f4f1994abbd67502c0b2c3b8e73ee0", + "distro": "a0e7521b2a1e3289e4118fad155277b200aebd35", "author": { "name": "Microsoft Corporation" }, From ae1080a9ed1098a8b532c6cfd50977d784b29e6d Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:48:02 -1000 Subject: [PATCH 37/49] chat: preserve unchanged history removals (#339635) * chat: preserve local removals when refreshing history Keep locally removed historical requests protected from passive history refreshes so checkpoint-discarded turns cannot reappear or change the next request's truncation boundary. Preserve provider-only removal and reintroduction behavior, with service/model/view regressions for explicit and generated request IDs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: retain removed anonymous history identities Remember locally removed anonymous request identities across provider omissions so reintroduced turns cannot bypass removal protection. Preserve unique-match safeguards and cover omission, repeated refreshes, predecessor selection, and ambiguous anonymous histories. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: limit removal protection to unchanged history Keep the fix scoped to the regression from #339458: unchanged history must not recreate a request removed locally. Remove persistent deletion identity tracking so changed and reintroduced turns retain their prior replay behavior, including genuinely new anonymous prompts. Add regression controls and clarify the history-refresh contract; leave older checkpoint-state and local-removal gaps out of scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/chatService/chatServiceImpl.ts | 3 +- .../chat/common/chatSessionsService.ts | 5 +- .../common/chatService/chatService.test.ts | 139 ++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 5e1ee1c6f22c..a32493518259 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1023,7 +1023,8 @@ export class ChatService extends Disposable implements IChatService { } const currentRequests = model.getRequests(); const existing = turn.id === undefined ? undefined : requestsById.get(turn.id); - if (existing && equals(turn.items, previousTurns.get(turn.id)?.items)) { + // An unchanged turn may be absent because the user removed it locally. + if (equals(turn.items, previousTurns.get(turn.id)?.items)) { continue; } let insertionIndex = existing ? currentRequests.indexOf(existing) : currentRequests.length; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index ff26657a9c82..48a54bc31a43 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -451,7 +451,10 @@ export interface IChatSession extends IDisposable { readonly title?: string; readonly history: readonly IChatSessionHistoryItem[]; - /** Updated persisted transcript; applying it must preserve the current draft and locally running requests. */ + /** + * Updated persisted transcript; applying it must preserve the draft and locally running requests. + * Unchanged turns must not resurrect locally removed requests. + */ readonly onDidChangeHistory?: Event; diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index bf13f9df6700..e74900ebd15b 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -3741,6 +3741,145 @@ suite('ChatService', () => { }); }); + for (const withRequestId of [true, false]) { + test(`passive history preserves checkpoint removals when a later local response changes (request ID: ${withRequestId})`, async () => { + const changes = testDisposables.add(new Emitter()); + const first: IChatSessionHistoryItem[] = [ + { type: 'request', id: 'retained', prompt: 'Retained request', participant: remoteScheme }, + { type: 'response', parts: [], participant: remoteScheme }, + { type: 'request', id: withRequestId ? 'removed' : undefined, prompt: 'Removed request', participant: remoteScheme }, + { type: 'response', parts: [], participant: remoteScheme }, + ]; + const { resource } = setupRemoteProvider({ history: first, onDidChangeHistory: changes.event }); + const service = createChatService(); + const ref = await service.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + const model = ref.object as ChatModel; + const viewModel = testDisposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); + const [retained, removed] = model.getRequests(); + const local = model.addRequest({ parts: [], text: 'Local request' }, { variables: [] }, 0); + local.response?.complete(); + const localHistory: IChatSessionHistoryItem[] = [ + { type: 'request', id: local.id, prompt: 'Local request', participant: remoteScheme }, + { type: 'response', parts: [], participant: remoteScheme, elapsedMs: 1000 }, + ]; + changes.fire([...first, ...localHistory]); + + model.setCheckpoint(removed.id); + for (const request of [...model.getRequests()].reverse()) { + if (request.shouldBeBlocked.get()) { + await service.removeRequest(resource, request.id); + } + } + model.setCheckpoint(undefined); + + const updated: IChatSessionHistoryItem[] = [ + ...first, + localHistory[0], + { type: 'response', parts: [], participant: remoteScheme, elapsedMs: 2000 }, + ]; + changes.fire(updated); + changes.fire(updated); + const next = model.addRequest({ parts: [], text: 'New branch' }, { variables: [] }, 0); + next.response?.complete(); + + assert.deepStrictEqual({ + requests: model.getRequests().map(request => request.message.text), + items: viewModel.getItems().filter(isRequestVM).map(item => item.messageText), + retained: model.getRequests()[0] === retained, + previousRequest: model.getRequests()[model.getRequests().indexOf(next) - 1] === retained, + }, { + requests: ['Retained request', 'New branch'], + items: ['Retained request', 'New branch'], + retained: true, + previousRequest: true, + }); + }); + } + + for (const withRequestId of [true, false]) { + for (const removedLocally of [true, false]) { + for (const omittedFirst of [true, false]) { + test(`passive history applies changed or reintroduced turns (removed locally: ${removedLocally}, request ID: ${withRequestId}, omitted first: ${omittedFirst})`, async () => { + const changes = testDisposables.add(new Emitter()); + const first: IChatSessionHistoryItem[] = [ + { type: 'request', id: 'retained', prompt: 'Retained request', participant: remoteScheme }, + { type: 'response', parts: [], participant: remoteScheme }, + { type: 'request', id: withRequestId ? 'repeated' : undefined, prompt: 'Repeated request', participant: remoteScheme }, + { type: 'response', parts: [], participant: remoteScheme }, + ]; + const { resource } = setupRemoteProvider({ history: first, onDidChangeHistory: changes.event }); + const service = createChatService(); + const ref = await service.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + const model = ref.object as ChatModel; + const viewModel = testDisposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); + if (removedLocally) { + await service.removeRequest(resource, model.getRequests()[1].id); + } + if (omittedFirst) { + changes.fire(first.slice(0, 2)); + } + for (const elapsedMs of [1000, 2000]) { + changes.fire([ + ...first.slice(0, 3), + { type: 'response', parts: [{ kind: 'markdownContent', content: new MarkdownString('New response') }], participant: remoteScheme, elapsedMs }, + ]); + } + const next = model.addRequest({ parts: [], text: 'New branch' }, { variables: [] }, 0); + next.response?.complete(); + + assert.deepStrictEqual({ + requests: model.getRequests().map(request => [request.message.text, request.response?.response.toString()]), + items: viewModel.getItems().filter(isRequestVM).map(item => item.messageText), + previousRequest: model.getRequests()[model.getRequests().indexOf(next) - 1].message.text, + }, { + requests: [['Retained request', ''], ['Repeated request', 'New response'], ['New branch', '']], + items: ['Retained request', 'Repeated request', 'New branch'], + previousRequest: 'Repeated request', + }); + }); + } + } + } + + for (const [previousCount, nextCount] of [[1, 1], [2, 1], [1, 2]]) { + test(`passive history does not reuse removed ID-less identities after omission: ${previousCount} to ${nextCount}`, async () => { + const changes = testDisposables.add(new Emitter()); + const first: IChatSessionHistoryItem[] = Array.from({ length: previousCount }, () => [ + { type: 'request' as const, prompt: 'Repeated request', participant: remoteScheme }, + { type: 'response' as const, parts: [], participant: remoteScheme }, + ]).flat(); + const updated: IChatSessionHistoryItem[] = Array.from({ length: nextCount }, () => [ + { type: 'request' as const, prompt: 'Repeated request', participant: remoteScheme }, + { type: 'response' as const, parts: [{ kind: 'markdownContent' as const, content: new MarkdownString('New response') }], participant: remoteScheme }, + ]).flat(); + const { resource } = setupRemoteProvider({ history: first, onDidChangeHistory: changes.event }); + const service = createChatService(); + const ref = await service.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + const model = ref.object as ChatModel; + const viewModel = testDisposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); + const previousIds = new Set(model.getRequests().map(request => request.id)); + for (const requestId of previousIds) { + await service.removeRequest(resource, requestId); + } + changes.fire([]); + changes.fire(updated); + + assert.deepStrictEqual({ + count: model.getRequests().length, + items: viewModel.getItems().filter(isRequestVM).length, + reusedRemovedId: model.getRequests().some(request => previousIds.has(request.id)), + uniqueIds: new Set(model.getRequests().map(request => request.id)).size, + responses: model.getRequests().map(request => request.response?.response.toString()), + }, { count: nextCount, items: nextCount, reusedRemovedId: false, uniqueIds: nextCount, responses: Array.from({ length: nextCount }, () => 'New response') }); + }); + } + test('passive history removals update the last request and cost before notifying observers', async () => { const changes = testDisposables.add(new Emitter()); const history: IChatSessionHistoryItem[] = [ From 3126b7743581c9eb4746ec47b0b5c533d6192975 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Mon, 5 Oct 2026 06:14:04 +0000 Subject: [PATCH 38/49] fix: memory leak in document paste edits (#338233) * fix: release document paste edits using their cache IDs * Keep paste edit sessions alive through alternative selection * Simplify paste edit lifetime with reference counting * Transfer paste edit ownership between replacement widgets * Encapsulate paste edit ownership in a session class * Return paste edit sessions directly from getPasteEdits --- .../browser/copyPasteController.ts | 54 +++---- .../browser/pasteEditSession.ts | 22 +++ .../dropOrPasteInto/browser/postEditWidget.ts | 39 +++-- .../test/browser/copyPasteController.test.ts | 142 ++++++++++++++++++ .../api/browser/mainThreadLanguageFeatures.ts | 5 +- .../api/common/extHostLanguageFeatures.ts | 2 +- .../browser/extHostLanguageFeatures.test.ts | 54 +++++++ 7 files changed, 271 insertions(+), 47 deletions(-) create mode 100644 src/vs/editor/contrib/dropOrPasteInto/browser/pasteEditSession.ts create mode 100644 src/vs/editor/contrib/dropOrPasteInto/test/browser/copyPasteController.test.ts diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts index abe335105607..515445a9c286 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts @@ -38,6 +38,7 @@ import { MessageController } from '../../message/browser/messageController.js'; import { PreferredPasteConfiguration } from './copyPasteContribution.js'; import { DefaultTextPasteOrDropEditProvider } from './defaultProviders.js'; import { createCombinedWorkspaceEdit, sortEditsByYieldTo } from './edit.js'; +import { PasteEditSession } from './pasteEditSession.js'; import { PostEditWidgetManager } from './postEditWidget.js'; export const changePasteTypeCommandId = 'editor.changePasteType'; @@ -60,11 +61,6 @@ type PasteEditWithProvider = DocumentPasteEdit & { }; -interface DocumentPasteWithProviderEditsSession { - edits: readonly PasteEditWithProvider[]; - dispose(): void; -} - export type PastePreference = | { readonly only: HierarchicalKind } | { readonly preferences: readonly HierarchicalKind[] } @@ -339,7 +335,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi const disposables = new DisposableStore(); const cts = disposables.add(new CancellationTokenSource(pToken)); - disposables.add(editorStateCts.token.onCancellationRequested(() => cts.cancel())); + const editorStateListener = disposables.add(editorStateCts.token.onCancellationRequested(() => cts.cancel())); const token = cts.token; try { @@ -359,8 +355,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi triggerKind: DocumentPasteTriggerKind.Automatic, }; - const editSession = await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, token); - disposables.add(editSession); + const editSession = disposables.add(await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, token)); if (token.isCancellationRequested) { return; } @@ -372,7 +367,9 @@ export class CopyPasteController extends Disposable implements IEditorContributi if (editSession.edits.length) { const canShowWidget = editor.getOption(EditorOption.pasteAs).showPasteSelector === 'afterPaste'; - return this._postPasteWidgetManager.applyEditAndShowIfNeeded(selections, { activeEditIndex: this.getInitialActiveEditIndex(model, editSession.edits), allEdits: editSession.edits }, canShowWidget, async (edit, resolveToken) => { + // Applying the paste changes editor state itself. The widget manager handles cancellation during resolve. + disposables.delete(editorStateListener); + return await this._postPasteWidgetManager.applyEditAndShowIfNeeded(selections, { activeEditIndex: this.getInitialActiveEditIndex(model, editSession.edits), allEdits: editSession.edits }, canShowWidget, async (edit, resolveToken) => { if (!edit.provider.resolveDocumentPasteEdit) { return edit; } @@ -388,7 +385,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi edit.additionalEdit = resolved.additionalEdit; } return edit; - }, token); + }, token, editSession); } await this.applyDefaultPasteHandler(dataTransfer, metadata, token, clipboardEvent); @@ -443,28 +440,26 @@ export class CopyPasteController extends Disposable implements IEditorContributi triggerKind: DocumentPasteTriggerKind.PasteAs, only: preference && 'only' in preference ? preference.only : undefined, }; - let editSession = disposables.add(await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, tokenSource.token)); + const editSession = disposables.add(await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, tokenSource.token)); if (tokenSource.token.isCancellationRequested) { return; } // Filter out any edits that don't match the requested kind + let edits = editSession.edits; if (preference) { - editSession = { - edits: editSession.edits.filter(edit => { - if ('only' in preference) { - return preference.only.contains(edit.kind); - } else if ('preferences' in preference) { - return preference.preferences.some(preference => preference.contains(edit.kind)); - } else { - return preference.providerId === edit.provider.id; - } - }), - dispose: editSession.dispose - }; + edits = edits.filter(edit => { + if ('only' in preference) { + return preference.only.contains(edit.kind); + } else if ('preferences' in preference) { + return preference.preferences.some(preference => preference.contains(edit.kind)); + } else { + return preference.providerId === edit.provider.id; + } + }); } - if (!editSession.edits.length) { + if (!edits.length) { if (preference) { this.showPasteAsNoEditMessage(selections, preference); } @@ -473,7 +468,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi let pickedEdit: DocumentPasteEdit | undefined; if (preference) { - pickedEdit = editSession.edits.at(0); + pickedEdit = edits.at(0); } else { type ItemWithEdit = IQuickPickItem & { edit?: DocumentPasteEdit }; const configureDefaultItem: ItemWithEdit = { @@ -484,7 +479,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi const selected = await this._quickInputService.pick( [ - ...editSession.edits.map((edit): ItemWithEdit => ({ + ...edits.map((edit): ItemWithEdit => ({ label: edit.title, description: edit.kind?.value, edit, @@ -596,7 +591,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi } } - private async getPasteEdits(providers: readonly DocumentPasteEditProvider[], dataTransfer: VSDataTransfer, model: ITextModel, selections: readonly Selection[], context: DocumentPasteContext, token: CancellationToken): Promise { + private async getPasteEdits(providers: readonly DocumentPasteEditProvider[], dataTransfer: VSDataTransfer, model: ITextModel, selections: readonly Selection[], context: DocumentPasteContext, token: CancellationToken): Promise> { const disposables = new DisposableStore(); const results = await raceCancellation( @@ -618,10 +613,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi const edits = coalesce(results ?? []).flat().filter(edit => { return !context.only || context.only.contains(edit.kind); }); - return { - edits: sortEditsByYieldTo(edits), - dispose: () => disposables.dispose() - }; + return new PasteEditSession(sortEditsByYieldTo(edits), disposables); } private async applyDefaultPasteHandler(dataTransfer: VSDataTransfer, metadata: CopyMetadata | undefined, token: CancellationToken, clipboardEvent: ClipboardEvent | undefined) { diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/pasteEditSession.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/pasteEditSession.ts new file mode 100644 index 000000000000..895841356d63 --- /dev/null +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/pasteEditSession.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { DocumentPasteEdit } from '../../../common/languages.js'; + +/** Owns cached paste edits until they are disposed or transferred to another session. */ +export class PasteEditSession extends Disposable { + private readonly _disposables = this._register(new MutableDisposable()); + + constructor(readonly edits: readonly T[], disposables: IDisposable | undefined) { + super(); + this._disposables.value = disposables; + } + + /** Transfers the edits to a new owner. Disposing this session will no longer release them. */ + take(): PasteEditSession { + return new PasteEditSession(this.edits, this._disposables.clearAndLeak()); + } +} diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.ts index 735768eb1758..0e09fd57b8f4 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.ts @@ -28,6 +28,7 @@ import { DocumentDropEdit, DocumentPasteEdit } from '../../../common/languages.j import { TrackedRangeStickiness } from '../../../common/model.js'; import { CodeEditorStateFlag, EditorStateCancellationTokenSource } from '../../editorState/browser/editorState.js'; import { createCombinedWorkspaceEdit } from './edit.js'; +import { PasteEditSession } from './pasteEditSession.js'; import './postEditWidget.css'; @@ -47,6 +48,8 @@ class PostEditWidget extends Dis readonly allowEditorOverflow = true; readonly suppressMouseDown = true; + private readonly _editSession: PasteEditSession | undefined; + private domNode!: HTMLElement; private button!: Button; @@ -59,14 +62,16 @@ class PostEditWidget extends Dis private readonly showCommand: ShowCommand, private readonly range: Range, private readonly edits: EditSet, - private readonly onSelectNewEdit: (editIndex: number) => void, + private readonly onSelectNewEdit: (editIndex: number, editSession: PasteEditSession | undefined) => void, private readonly additionalActions: readonly IAction[], + editSessionOwner: PasteEditSession | undefined, @IContextKeyService contextKeyService: IContextKeyService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, ) { super(); + this._editSession = editSessionOwner ? this._register(editSessionOwner.take()) : undefined; this.create(); this.visibleContext = visibleContext.bindTo(contextKeyService); @@ -140,7 +145,8 @@ class PostEditWidget extends Dis const i = this.edits.allEdits.findIndex(edit => edit === item); if (i !== this.edits.activeEditIndex) { - return this.onSelectNewEdit(i); + // Transfer the edits before undo disposes this widget. + return this.onSelectNewEdit(i, this._editSession?.take()); } }, }, anchor, this.editor.getDomNode() ?? undefined, this.additionalActions); @@ -169,7 +175,7 @@ export class PostEditWidgetManager this.clear())); } - public async applyEditAndShowIfNeeded(ranges: readonly Range[], edits: EditSet, canShowWidget: boolean, resolve: (edit: T, token: CancellationToken) => Promise, token: CancellationToken) { + public async applyEditAndShowIfNeeded(ranges: readonly Range[], edits: EditSet, canShowWidget: boolean, resolve: (edit: T, token: CancellationToken) => Promise, token: CancellationToken, editSessionOwner?: PasteEditSession) { if (!ranges.length || !this._editor.hasModel()) { return; } @@ -180,14 +186,19 @@ export class PostEditWidgetManager { - const model = this._editor.getModel(); - if (!model) { - return; - } + const onDidSelectEdit = async (newEditIndex: number, editSession: PasteEditSession | undefined) => { + try { + this.clear(); + const model = this._editor.getModel(); + if (!model) { + return; + } - await model.undo(); - this.applyEditAndShowIfNeeded(ranges, { activeEditIndex: newEditIndex, allEdits: edits.allEdits }, canShowWidget, resolve, token); + await model.undo(); + await this.applyEditAndShowIfNeeded(ranges, { activeEditIndex: newEditIndex, allEdits: edits.allEdits }, canShowWidget, resolve, token, editSession); + } finally { + editSession?.dispose(); + } }; const handleError = (e: Error, message: string) => { @@ -197,7 +208,7 @@ export class PostEditWidgetManager 1) { - this.show(editRange ?? primaryRange, edits, onDidSelectEdit); + this.show(editRange ?? primaryRange, edits, onDidSelectEdit, editSessionOwner); } } - public show(range: Range, edits: EditSet, onDidSelectEdit: (newIndex: number) => void) { + public show(range: Range, edits: EditSet, onDidSelectEdit: (newIndex: number, editSession: PasteEditSession | undefined) => void, editSessionOwner?: PasteEditSession) { this.clear(); if (this._editor.hasModel()) { - this._currentWidget.value = this._instantiationService.createInstance(PostEditWidget, this._id, this._editor, this._visibleContext, this._showCommand, range, edits, onDidSelectEdit, this._getAdditionalActions()); + this._currentWidget.value = this._instantiationService.createInstance(PostEditWidget, this._id, this._editor, this._visibleContext, this._showCommand, range, edits, onDidSelectEdit, this._getAdditionalActions(), editSessionOwner); } } diff --git a/src/vs/editor/contrib/dropOrPasteInto/test/browser/copyPasteController.test.ts b/src/vs/editor/contrib/dropOrPasteInto/test/browser/copyPasteController.test.ts new file mode 100644 index 000000000000..7267431088b8 --- /dev/null +++ b/src/vs/editor/contrib/dropOrPasteInto/test/browser/copyPasteController.test.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import sinon from 'sinon'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { createStringDataTransferItem, VSDataTransfer } from '../../../../../base/common/dataTransfer.js'; +import { HierarchicalKind } from '../../../../../base/common/hierarchicalKind.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IProgressService } from '../../../../../platform/progress/common/progress.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IBulkEditService, ResourceEdit, ResourceTextEdit } from '../../../../browser/services/bulkEditService.js'; +import { DocumentPasteEditProvider, WorkspaceEdit } from '../../../../common/languages.js'; +import { withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; +import { SnippetParser } from '../../../snippet/browser/snippetParser.js'; +import { CopyPasteController } from '../../browser/copyPasteController.js'; + +suite('CopyPasteController - paste edit session', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + for (const endInteraction of ['dismiss', 'cursor', 'content', 'model', 'dispose', 'noSelector', 'singleEdit', 'cancelReplacement', 'failedUndo'] as const) { + let name = `resolves alternatives after the initial paste and releases edits on ${endInteraction}`; + switch (endInteraction) { + case 'noSelector': name = 'releases edits after paste when the selector is disabled'; break; + case 'singleEdit': name = 'releases edits after paste when there are no alternatives'; break; + case 'cancelReplacement': name = 'releases transferred edits when alternative resolution is cancelled'; break; + case 'failedUndo': name = 'releases transferred edits when undo fails'; break; + } + test(name, async () => { + await withAsyncTestCodeEditor('', { pasteAs: { enabled: true, showPasteSelector: endInteraction === 'noSelector' ? 'never' : 'afterPaste' } }, async (editor, _viewModel, instantiationService) => { + instantiationService.stub(createDecorator<{ add(): () => void }>('IEditorCancelService'), { add: () => () => { } }); + let selectEdit: ((index: number) => Promise) | undefined; + instantiationService.stub(IActionWidgetService, new class extends mock() { + override show: IActionWidgetService['show'] = (_user, _preview, items, delegate) => { + selectEdit = async index => { await delegate.onSelect(items[index].item!); }; + }; + override hide() { } + }); + instantiationService.stub(IBulkEditService, new class extends mock() { + override async apply(edit: ResourceEdit[] | WorkspaceEdit) { + const textEdits = (Array.isArray(edit) ? edit : edit.edits).map(edit => { + assert.ok(ResourceTextEdit.is(edit)); + return { range: edit.textEdit.range, text: new SnippetParser().parse(edit.textEdit.text).toString() }; + }); + editor.getModel().pushStackElement(); + editor.getModel().pushEditOperations(editor.getSelections(), textEdits, () => null); + editor.getModel().pushStackElement(); + return { isApplied: true, ariaSummary: '' }; + } + }); + instantiationService.stub(IQuickInputService, new class extends mock() { }); + instantiationService.stub(IProgressService, new class extends mock() { }); + + let released = 0; + let resolves = 0; + const resolveStarted = new DeferredPromise(); + const finishResolve = new DeferredPromise(); + const plainKind = new HierarchicalKind('text.plain'); + const provider: DocumentPasteEditProvider = { + copyMimeTypes: [], + pasteMimeTypes: ['text/plain'], + providedPasteEditKinds: [plainKind], + async provideDocumentPasteEdits() { + return { + edits: [ + { title: 'Resolve test', insertText: 'UNRESOLVED', kind: new HierarchicalKind('test'), yieldTo: [{ kind: plainKind }] }, + { title: 'Plain text', insertText: 'PLAIN', kind: plainKind } + ].slice(endInteraction === 'singleEdit' ? 1 : 0), + dispose: () => { ++released; } + }; + }, + async resolveDocumentPasteEdit(edit) { + assert.strictEqual(released, 0, 'Provider edits must still be cached during resolve'); + ++resolves; + if (endInteraction === 'cancelReplacement' && edit.title === 'Resolve test') { + resolveStarted.complete(); + await finishResolve.p; + } + return { ...edit, insertText: edit.title === 'Resolve test' ? 'RESOLVED' : edit.insertText }; + } + }; + const controller = disposables.add(instantiationService.createInstance(CopyPasteController, editor)); + const dataTransfer = new VSDataTransfer(); + dataTransfer.append('text/plain', createStringDataTransferItem('PLAIN')); + // The test editor has no clipboard view, so start the inline paste directly. + // eslint-disable-next-line local/code-no-bracket-notation-for-identifiers + controller['doPasteInline']([provider], editor.getSelections(), dataTransfer, undefined, undefined); + await controller.finishedPaste(); + if (endInteraction === 'noSelector' || endInteraction === 'singleEdit') { + assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'PLAIN', released: 1, resolves: 1 }); + return; + } + assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'PLAIN', released: 0, resolves: 1 }); + + controller.changePasteType(); + assert.ok(selectEdit); + if (endInteraction === 'failedUndo') { + const undo = sinon.stub(editor.getModel(), 'undo').rejects(new Error('Undo failed')); + try { + await assert.rejects(selectEdit(1), /Undo failed/); + } finally { + undo.restore(); + } + controller.clearWidgets(); + assert.strictEqual(released, 1); + return; + } + if (endInteraction === 'cancelReplacement') { + const selection = selectEdit(1); + await resolveStarted.p; + editor.getModel().setValue('changed'); + finishResolve.complete(); + await selection; + controller.clearWidgets(); + assert.deepStrictEqual({ text: editor.getValue(), released }, { text: 'changed', released: 1 }); + return; + } + await selectEdit(1); + assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'RESOLVED', released: 0, resolves: 2 }); + + controller.changePasteType(); + await selectEdit(0); + assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'PLAIN', released: 0, resolves: 3 }); + + switch (endInteraction) { + case 'dismiss': controller.clearWidgets(); break; + case 'cursor': editor.setPosition({ lineNumber: 1, column: 2 }); break; + case 'content': editor.getModel().setValue('changed'); break; + case 'model': editor.setModel(null); break; + case 'dispose': controller.dispose(); break; + } + controller.clearWidgets(); + assert.strictEqual(released, 1); + }); + }); + } +}); diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index d24f1ebaf71c..6b724dec027e 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -1131,7 +1131,10 @@ class MainThreadPasteEditProvider implements languages.DocumentPasteEditProvider }; }), dispose: () => { - this._proxy.$releasePasteEdits(this._handle, request.id); + const cacheId = edits[0]?._cacheId?.[0]; + if (typeof cacheId === 'number') { + this._proxy.$releasePasteEdits(this._handle, cacheId); + } }, }; } finally { diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index fffb8d9c7f45..bdf8bc6bcc9e 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -665,7 +665,7 @@ class DocumentPasteEditProvider { only: context.only ? new DocumentDropOrPasteEditKind(context.only) : undefined, triggerKind: context.triggerKind, }, token); - if (!edits || token.isCancellationRequested) { + if (!edits?.length || token.isCancellationRequested) { return []; } diff --git a/src/vs/workbench/api/test/browser/extHostLanguageFeatures.test.ts b/src/vs/workbench/api/test/browser/extHostLanguageFeatures.test.ts index 661cff2bd794..5f1836132a37 100644 --- a/src/vs/workbench/api/test/browser/extHostLanguageFeatures.test.ts +++ b/src/vs/workbench/api/test/browser/extHostLanguageFeatures.test.ts @@ -13,6 +13,8 @@ import * as types from '../../common/extHostTypes.js'; import { createTextModel } from '../../../../editor/test/common/testTextModel.js'; import { Position as EditorPosition, Position } from '../../../../editor/common/core/position.js'; import { Range as EditorRange } from '../../../../editor/common/core/range.js'; +import { Selection } from '../../../../editor/common/core/selection.js'; +import { Cache } from '../../common/cache.js'; import { TestRPCProtocol } from '../common/testRPCProtocol.js'; import { IMarkerService } from '../../../../platform/markers/common/markers.js'; import { MarkerService } from '../../../../platform/markers/common/markerService.js'; @@ -192,6 +194,58 @@ suite('ExtHostLanguageFeatures', function () { }); } + // --- paste edits + + for (const declinedRequests of [0, 2]) { + test(`DocumentPasteEdits releases cache IDs after ${declinedRequests} declined requests`, async () => { + let requests = 0; + disposables.add(extHost.registerDocumentPasteEditProvider(defaultExtension, defaultSelector, { + provideDocumentPasteEdits: () => ++requests <= declinedRequests ? undefined : [new types.DocumentPasteEdit('pasted text', 'Test paste', types.DocumentDropOrPasteEditKind.Text)] + }, { pasteMimeTypes: ['text/plain'], providedPasteEditKinds: [types.DocumentDropOrPasteEditKind.Text] })); + await rpcProtocol.sync(); + const provider = languageFeaturesService.documentPasteEditProvider.all(model)[0]; + const release = sinon.spy(extHost, '$releasePasteEdits'); + const provide = () => provider.provideDocumentPasteEdits!(model, [new Selection(1, 1, 1, 1)], new VSDataTransfer(), { triggerKind: languages.DocumentPasteTriggerKind.Automatic }, CancellationToken.None); + try { + for (let i = 0; i < declinedRequests; i++) { + const declined = await provide(); + declined?.dispose(); + } + await rpcProtocol.sync(); + release.resetHistory(); + const first = await provide(); + assert.ok(first); + disposables.add(first); + const second = await provide(); + assert.ok(second); + disposables.add(second); + second.dispose(); + first.dispose(); + await rpcProtocol.sync(); + assert.deepStrictEqual(release.args.map(([, cacheId]) => cacheId), [2, 1]); + } finally { + release.restore(); + } + }); + } + + test('DocumentPasteEdits does not cache empty results without release IDs', async () => { + disposables.add(extHost.registerDocumentPasteEditProvider(defaultExtension, defaultSelector, { + provideDocumentPasteEdits: () => [] + }, { pasteMimeTypes: ['text/plain'], providedPasteEditKinds: [types.DocumentDropOrPasteEditKind.Text] })); + await rpcProtocol.sync(); + const provider = languageFeaturesService.documentPasteEditProvider.all(model)[0]; + const cache = sinon.spy(Cache.prototype, 'add'); + try { + const result = await provider.provideDocumentPasteEdits!(model, [new Selection(1, 1, 1, 1)], new VSDataTransfer(), { triggerKind: languages.DocumentPasteTriggerKind.Automatic }, CancellationToken.None); + result?.dispose(); + await rpcProtocol.sync(); + assert.strictEqual(cache.callCount, 0); + } finally { + cache.restore(); + } + }); + // --- outline test('DocumentSymbols, register/deregister', async () => { From a8fe268543e25fa2a52a6b77b0124a9a8f50a692 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Mon, 5 Oct 2026 06:14:25 +0000 Subject: [PATCH 39/49] fix: memory leak in native context menu dismissal (#338214) * fix: release native context menu callbacks on dismissal * fix: preserve file URLs in native context menu test fixture * test: remove native context menu lifecycle fixture --- src/vs/base/parts/contextmenu/electron-browser/contextmenu.ts | 4 +--- src/vs/base/parts/contextmenu/electron-main/contextmenu.ts | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/base/parts/contextmenu/electron-browser/contextmenu.ts b/src/vs/base/parts/contextmenu/electron-browser/contextmenu.ts index 7b1971d0aa7e..f5187f617ea1 100644 --- a/src/vs/base/parts/contextmenu/electron-browser/contextmenu.ts +++ b/src/vs/base/parts/contextmenu/electron-browser/contextmenu.ts @@ -17,7 +17,7 @@ export function popup(items: IContextMenuItem[], options?: IPopupOptions, onHide const itemId = args[0] as number; const context = args[1] as IContextMenuEvent; const item = processedItems[itemId]; - item.click?.(context); + item?.click?.(context); }; ipcRenderer.once(onClickChannel, onClickChannelHandler); @@ -27,8 +27,6 @@ export function popup(items: IContextMenuItem[], options?: IPopupOptions, onHide return; } - ipcRenderer.removeListener(onClickChannel, onClickChannelHandler); - onHide?.(); }); diff --git a/src/vs/base/parts/contextmenu/electron-main/contextmenu.ts b/src/vs/base/parts/contextmenu/electron-main/contextmenu.ts index 988c43285f14..a61213ef7103 100644 --- a/src/vs/base/parts/contextmenu/electron-main/contextmenu.ts +++ b/src/vs/base/parts/contextmenu/electron-main/contextmenu.ts @@ -20,6 +20,8 @@ export function registerContextMenuListener(): void { // It turns out that the menu gets GC'ed if not referenced anymore // As such we drag it into this scope so that it is not being GC'ed if (menu) { + // Complete the one-shot click listener even when no item was selected. + event.sender.send(onClickChannel, -1); event.sender.send(CONTEXT_MENU_CLOSE_CHANNEL, contextMenuId); } } From 48573218e0a08469a8da454b5c005472239425e1 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Mon, 5 Oct 2026 06:16:06 +0000 Subject: [PATCH 40/49] fix: memory leak in output channel disposal listeners (#338240) * fix: release output channel disposal listeners with their channel * test: cover output disposal retention without explicit GC * test: isolate output channel disposal from shared registry Save and clear pre-existing output channel descriptors before constructing the test service, then restore them after its disposal. This prevents order-dependent active-channel assertions and initialization-after-shutdown leaks when agent-session tests run first. --- .../contrib/output/browser/outputServices.ts | 11 +- .../test/browser/outputServices.test.ts | 138 ++++++++++++++++++ 2 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 src/vs/workbench/contrib/output/test/browser/outputServices.test.ts diff --git a/src/vs/workbench/contrib/output/browser/outputServices.ts b/src/vs/workbench/contrib/output/browser/outputServices.ts index 0093c7efe79e..01c80a09be6e 100644 --- a/src/vs/workbench/contrib/output/browser/outputServices.ts +++ b/src/vs/workbench/contrib/output/browser/outputServices.ts @@ -45,6 +45,7 @@ class OutputChannel extends Disposable implements IOutputChannel { readonly outputChannelDescriptor: IOutputChannelDescriptor, private readonly outputLocation: URI, private readonly outputDirPromise: Promise, + onDispose: () => void, @ILanguageService private readonly languageService: ILanguageService, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { @@ -53,6 +54,7 @@ class OutputChannel extends Disposable implements IOutputChannel { this.label = outputChannelDescriptor.label; this.uri = URI.from({ scheme: Schemas.outputChannel, path: this.id }); this.model = this._register(this.createOutputChannelModel(this.uri, outputChannelDescriptor)); + this._register(Event.once(this.model.onDispose)(onDispose)); } private createOutputChannelModel(uri: URI, outputChannelDescriptor: IOutputChannelDescriptor): IOutputChannelModel { @@ -565,8 +567,7 @@ export class OutputService extends Disposable implements IOutputService, ITextMo } private createChannel(id: string): OutputChannel { - const channel = this.instantiateChannel(id); - this._register(Event.once(channel.model.onDispose)(() => { + const channel = this.instantiateChannel(id, () => { if (this.activeChannel === channel) { const channels = this.getChannelDescriptors(); const channel = channels.length ? this.getChannel(channels[0].id) : undefined; @@ -577,13 +578,13 @@ export class OutputService extends Disposable implements IOutputService, ITextMo } } Registry.as(Extensions.OutputChannels).removeChannel(id); - })); + }); return channel; } private outputFolderCreationPromise: Promise | null = null; - private instantiateChannel(id: string): OutputChannel { + private instantiateChannel(id: string, onDispose: () => void): OutputChannel { const channelData = Registry.as(Extensions.OutputChannels).getChannel(id); if (!channelData) { this.logService.error(`Channel '${id}' is not registered yet`); @@ -592,7 +593,7 @@ export class OutputService extends Disposable implements IOutputService, ITextMo if (!this.outputFolderCreationPromise) { this.outputFolderCreationPromise = this.fileService.createFolder(this.outputLocation).then(() => undefined); } - return this.instantiationService.createInstance(OutputChannel, channelData, this.outputLocation, this.outputFolderCreationPromise); + return this.instantiationService.createInstance(OutputChannel, channelData, this.outputLocation, this.outputFolderCreationPromise, onDispose); } private setLevelContext(): void { diff --git a/src/vs/workbench/contrib/output/test/browser/outputServices.test.ts b/src/vs/workbench/contrib/output/test/browser/outputServices.test.ts new file mode 100644 index 000000000000..c484e8beeaa4 --- /dev/null +++ b/src/vs/workbench/contrib/output/test/browser/outputServices.test.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { Event } from '../../../../../base/common/event.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { IWebWorkerService } from '../../../../../platform/webWorker/browser/webWorkerService.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { Workspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; +import { IDefaultLogLevelsService } from '../../../../services/log/common/defaultLogLevels.js'; +import { Extensions, IOutputChannel, IOutputChannelDescriptor, IOutputChannelRegistry } from '../../../../services/output/common/output.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { TestViewsService, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { TestContextService } from '../../../../test/common/workbenchTestServices.js'; +import { OutputService } from '../../browser/outputServices.js'; + +suite('OutputService channel disposal', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const registry = Registry.as(Extensions.OutputChannels); + let originalChannels: IOutputChannelDescriptor[]; + let service: OutputService; + let nextId = 0; + + setup(() => { + // Earlier suites can leave source-less channels with asynchronous model initialization. + originalChannels = registry.getChannels(); + for (const channel of originalChannels) { + registry.removeChannel(channel.id); + } + + const instantiationService = workbenchInstantiationService({}, disposables); + instantiationService.stub(IViewsService, new TestViewsService()); + instantiationService.stub(IWorkspaceContextService, new TestContextService(new Workspace('output-test', []))); + instantiationService.stub(IWebWorkerService, new class extends mock() { }); + instantiationService.stub(IDefaultLogLevelsService, new class extends mock() { + override readonly onDidChangeDefaultLogLevels = Event.None; + }); + service = disposables.add(instantiationService.createInstance(OutputService)); + }); + + teardown(() => { + // The disposable tracker tears down the service before restoring the shared registry. + for (const channel of registry.getChannels()) { + registry.removeChannel(channel.id); + } + for (const channel of originalChannels) { + registry.registerChannel(channel); + } + }); + + function registerChannel() { + const id = `output-disposal-test-${nextId++}`; + registry.registerChannel({ id, label: id, log: false, source: { resource: URI.file(`/logs/${id}.log`) } }); + return service.getChannel(id)!; + } + + function disposeChannel(): WeakRef { + const channel = registerChannel(); + const ref = new WeakRef(channel); + channel.dispose(); + assert.strictEqual(service.getChannel(channel.id), undefined); + return ref; + } + + for (const removeFromRegistry of [false, true]) { + test(`does not retain disposal listeners after ${removeFromRegistry ? 'registry removal' : 'channel disposal'}`, () => { + registerChannel(); + // eslint-disable-next-line local/code-no-bracket-notation-for-identifiers -- Inspect service-owned registrations without exposing test-only API. + const registrations = service['_store']['_toDispose']; + const initialRegistrations = [...registrations]; + for (let i = 0; i < 3; i++) { + const channel = registerChannel(); + if (removeFromRegistry) { + registry.removeChannel(channel.id); + } else { + channel.dispose(); + } + } + assert.deepStrictEqual([...registrations], initialRegistrations); + }); + } + + test('disposed channels become collectible while the service remains alive', async function () { + if (typeof globalThis.gc !== 'function') { + this.skip(); // Run the Electron suite with --js-flags=--expose-gc. + } + registerChannel(); // Keep another channel open, as in the workbench. + const ref = disposeChannel(); + // A WeakRef keeps its target alive until the end of the current job. + await timeout(0); + await globalThis.gc!({ type: 'major', execution: 'async' }); + assert.strictEqual(ref.deref() === undefined, true, 'Disposed output channel is still strongly retained'); + }); + + test('disposing the active channel preserves another open channel', async () => { + const remaining = registerChannel(); + const closing = registerChannel(); + await service.showChannel(closing.id); + closing.dispose(); + assert.deepStrictEqual({ + active: service.getActiveChannel()?.id, + remaining: service.getChannel(remaining.id)?.id, + removed: registry.getChannel(closing.id) + }, { active: remaining.id, remaining: remaining.id, removed: undefined }); + }); + + test('registry removal releases its channel while the service remains alive', async function () { + if (typeof globalThis.gc !== 'function') { + this.skip(); + } + registerChannel(); + const ref = (() => { + const channel = registerChannel(); + const ref = new WeakRef(channel); + registry.removeChannel(channel.id); + assert.strictEqual(service.getChannel(channel.id), undefined); + return ref; + })(); + await timeout(0); + await globalThis.gc!({ type: 'major', execution: 'async' }); + assert.strictEqual(ref.deref() === undefined, true, 'Removed output channel is still strongly retained'); + }); + + test('service shutdown still disposes open channels once', () => { + const channel = registerChannel(); + let disposed = 0; + disposables.add(channel.model.onDispose(() => disposed++)); + service.dispose(); + service.dispose(); + assert.deepStrictEqual({ disposed, registered: registry.getChannel(channel.id) }, { disposed: 1, registered: undefined }); + }); +}); From 2dca67a07aba894351849f39d337a921758722e8 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Mon, 5 Oct 2026 09:34:02 +0200 Subject: [PATCH 41/49] test: Align Node unit test timeouts with Electron (#339290) Configure the actual Mocha runner with local and CI defaults, and forward explicit timeout overrides instead of relying on the outer CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 2 +- test/unit/README.md | 6 ++++++ test/unit/node/index.js | 13 +++++++++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 203e150150f1..85c1f4842d39 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "node -e \"console.error('Run a test script from the scripts folder, for example: ./scripts/test.sh --run .'); process.exit(1)\"", "test-browser": "npx playwright install && node test/unit/browser/index.js", "test-browser-no-install": "node test/unit/browser/index.js", - "test-node": "mocha test/unit/node/index.js --delay --ui=tdd --timeout=5000 --exit", + "test-node": "mocha test/unit/node/index.js --delay --ui=tdd --exit", "test-extension": "vscode-test", "test-build-scripts": "cd build && npm run test", "test-agent-host-e2e": "node scripts/test-agent-host-e2e.ts", diff --git a/test/unit/README.md b/test/unit/README.md index 0a825b3c0b74..c630c4aa74ed 100644 --- a/test/unit/README.md +++ b/test/unit/README.md @@ -29,6 +29,12 @@ Unit tests from layers `common` and `browser` are run inside `chromium`, `webkit npm run test-node -- --run src/vs/editor/test/browser/controller/cursor.test.ts +The default test timeout is 5 seconds locally and 30 seconds in CI, matching the Electron runner. CI is detected through `BUILD_ARTIFACTSTAGINGDIRECTORY` or `GITHUB_WORKSPACE`. + +Use `--timeout ` (or `-t `) to override the default, or `--timeout 0` to disable timeouts. Explicit suite and test timeouts still take precedence. + + npm run test-node -- --timeout 10000 --run src/vs/base/test/common/async.test.ts + ## Coverage The following command will create a `coverage` folder in the `.build` folder at the root of the workspace: diff --git a/test/unit/node/index.js b/test/unit/node/index.js index 2e80f5eb9d7c..f01f27becd3f 100644 --- a/test/unit/node/index.js +++ b/test/unit/node/index.js @@ -20,13 +20,14 @@ import { fileURLToPath, pathToFileURL } from 'url'; import semver from 'semver'; /** - * @type {{ build: boolean; run: string; runGlob: string; coverage: boolean; help: boolean; coverageFormats: string | string[]; coveragePath: string; }} + * @type {{ build: boolean; run: string; runGlob: string; coverage: boolean; help: boolean; coverageFormats: string | string[]; coveragePath: string; timeout?: string; }} */ const args = minimist(process.argv.slice(2), { boolean: ['build', 'coverage', 'help'], - string: ['run', 'coveragePath', 'coverageFormats'], + string: ['run', 'coveragePath', 'coverageFormats', 'timeout'], alias: { - h: 'help' + h: 'help', + t: 'timeout' }, default: { build: false, @@ -39,6 +40,7 @@ const args = minimist(process.argv.slice(2), { coverage: 'Generate a coverage report', coveragePath: 'Path to coverage report to generate', coverageFormats: 'Coverage formats to generate', + timeout: 'Timeout for tests', help: 'Show help' } }); @@ -50,11 +52,13 @@ Options: --build Run from out-build --run Run a single file --coverage Generate a coverage report +--timeout Test timeout (default: 5000 locally, 30000 in CI; alias: -t) --help Show help`); process.exit(0); } const TEST_GLOB = '**/test/**/*.test.js'; +const IS_CI = !!process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || !!process.env.GITHUB_WORKSPACE; const excludeGlobs = [ '**/{browser,electron-browser,electron-main,electron-utility}/**/*.test.js', @@ -148,7 +152,8 @@ function main() { const runner = new Mocha({ - ui: 'tdd' + ui: 'tdd', + timeout: args.timeout ?? (IS_CI ? 30000 : 5000) }); /** From a52d7d628e1a4c8fb9196cad37b3b456b88dadaa Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:38:33 +0200 Subject: [PATCH 42/49] Git - fix duplicate co-authors in git blame hover for squash merges (#339678) Agent Host changes for lszomoru/agents/vscode-issue-331109-investigation --- extensions/git/src/git.ts | 7 ++++++- extensions/git/src/test/git.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 204f49245096..0ad28a71fa68 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -979,13 +979,18 @@ const coAuthorRegex = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$/gim; export function parseCoAuthors(message: string): CoAuthor[] { const coAuthors: CoAuthor[] = []; + const emails = new Set(); let match; coAuthorRegex.lastIndex = 0; while ((match = coAuthorRegex.exec(message)) !== null) { const name = match[1].trim(); const email = match[2].trim(); - if (name && email) { + + // Squash merge commit messages repeat the trailers of each squashed commit + const emailKey = email.toLowerCase(); + if (name && email && !emails.has(emailKey)) { + emails.add(emailKey); coAuthors.push({ name, email }); } } diff --git a/extensions/git/src/test/git.test.ts b/extensions/git/src/test/git.test.ts index b9c08fb907fa..974afb66e7f8 100644 --- a/extensions/git/src/test/git.test.ts +++ b/extensions/git/src/test/git.test.ts @@ -642,6 +642,32 @@ suite('git', () => { [{ name: 'Jane Doe', email: 'jane@example.com' }] ); }); + + test('duplicate co-authors (squash merge)', function () { + assert.deepStrictEqual( + parseCoAuthors([ + 'Fix bug (#123)', + '', + '* First change', + '', + 'Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>', + '', + '* Second change', + '', + 'Co-authored-by: Jane Doe ', + 'Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>', + '', + '---------', + '', + 'Co-authored-by: Copilot <223556219+copilot@users.noreply.github.com>', + 'Co-authored-by: Jane ' + ].join('\n')), + [ + { name: 'Copilot', email: '223556219+Copilot@users.noreply.github.com' }, + { name: 'Jane Doe', email: 'jane@example.com' } + ] + ); + }); }); suite('splitInChunks', () => { From 45a59e27c2f2d6975de5faccc19dabf151216b50 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:27:56 +0200 Subject: [PATCH 43/49] Agents - fix branch picker defaults for New Worktree and Branch selection (#339701) Agent Host changes for lszomoru/agents/branch-picker-defaults-fix --- .../agentHost/browser/baseAgentHostSessionsProvider.ts | 4 ++-- .../browser/localAgentHostSessionsProvider.test.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index c10bfdcfea01..f8a84e202eaa 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -5343,8 +5343,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Mirror the host default so the chip does not flash while the config resolves. const repository = newSession.session.workspace.get()?.folders[0]?.gitRepository; const defaultBranchName = normalizedValue === 'worktree' - ? repository?.upstreamBranchName ?? repository?.defaultBranchName - : undefined; + ? repository?.defaultRemoteBranchName ?? repository?.defaultBranchName + : repository?.branchName; if (workspace.baseBranch && isSessionConfigWritable(workspace.baseBranch.schema, true)) { newSession.setConfigValue(workspace.baseBranch.key, defaultBranchName); } 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 3a782e42e260..dad746793aa4 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 @@ -5821,7 +5821,7 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('selects the current branch upstream when New Worktree is toggled on', async () => { + test('selects the default remote branch for New Worktree and the current branch for Branch', async () => { agentHost.resolveSessionConfigResult = { schema: createVSCodeSessionConfigSchema(), values: { isolation: 'folder', branch: 'feature' }, @@ -5842,7 +5842,7 @@ suite('LocalAgentHostSessionsProvider', () => { const firstToggleRequest = agentHost.resolveSessionConfigRequests.length; agentHost.resolveSessionConfigResult = { schema: createVSCodeSessionConfigSchema(), - values: { isolation: 'worktree', branch: 'origin/feature' }, + values: { isolation: 'worktree', branch: 'origin/main' }, }; await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Isolation, 'worktree'); @@ -5861,10 +5861,10 @@ suite('LocalAgentHostSessionsProvider', () => { }, { repository: 'origin/feature', requests: [ - { isolation: 'worktree', branch: 'origin/feature' }, - { isolation: 'folder' }, + { isolation: 'worktree', branch: 'origin/main' }, + { isolation: 'folder', branch: 'feature' }, ], - worktreeConfig: { isolation: 'worktree', branch: 'origin/feature' }, + worktreeConfig: { isolation: 'worktree', branch: 'origin/main' }, config: { isolation: 'folder', branch: 'feature' }, }); }); From 1e72bb9dd274d2c6d50f9ddbb85e9ae26104f5e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Crem=20Sancaktutan?= <36550960+codelovingcat@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:38:28 +0300 Subject: [PATCH 44/49] Fix/issue 337524 multidiff order (#337565) * Fix SCM multi-diff file ordering * test: cover SCM multi-diff file ordering * test: cover SCM multi-diff file ordering * test: simplify SCM multi-diff ordering coverage * test: organize SCM multi-diff test imports * test: fix SCM multi-diff review findings --- .../browser/scmMultiDiffSourceResolver.ts | 5 +- .../scmMultiDiffSourceResolver.test.ts | 74 ++++++++++++++++++- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts index 0d5f4528f05b..86dbc6b19e00 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts @@ -7,6 +7,7 @@ import { ValueWithChangeEvent } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { observableFromEvent, ValueWithChangeEventFromObservable, waitForState } from '../../../../base/common/observable.js'; import { basename } from '../../../../base/common/path.js'; +import { comparePaths } from '../../../../base/common/comparers.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { localize2 } from '../../../../nls.js'; @@ -176,7 +177,9 @@ class ScmResolvedMultiDiffSource implements IResolvedMultiDiffSource { ) { this._resources = observableFromEvent( this._group.onDidChangeResources, - () => /** @description resources */ this._group.resources.map(e => new MultiDiffEditorItem(e.multiDiffEditorOriginalUri, e.multiDiffEditorModifiedUri, e.sourceUri)) + () => /** @description resources */[...this._group.resources] + .sort((a, b) => comparePaths(a.sourceUri.fsPath, b.sourceUri.fsPath)) + .map(e => new MultiDiffEditorItem(e.multiDiffEditorOriginalUri, e.multiDiffEditorModifiedUri, e.sourceUri)) ); this.resources = new ValueWithChangeEventFromObservable(this._resources); this.contextKeys = { diff --git a/src/vs/workbench/contrib/multiDiffEditor/test/browser/scmMultiDiffSourceResolver.test.ts b/src/vs/workbench/contrib/multiDiffEditor/test/browser/scmMultiDiffSourceResolver.test.ts index 313da4a454d0..7fa1520e0956 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/test/browser/scmMultiDiffSourceResolver.test.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/test/browser/scmMultiDiffSourceResolver.test.ts @@ -4,14 +4,82 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Emitter } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActivityService } from '../../../../services/activity/common/activity.js'; import { ISCMHistoryProvider } from '../../../scm/common/history.js'; -import { ISCMProvider, ISCMRepository, ISCMService } from '../../../scm/common/scm.js'; -import { ScmHistoryItemResolver } from '../../browser/scmMultiDiffSourceResolver.js'; +import { ISCMProvider, ISCMRepository, ISCMResource, ISCMResourceGroup, ISCMService } from '../../../scm/common/scm.js'; +import { ScmMultiDiffSourceResolver, ScmHistoryItemResolver } from '../../browser/scmMultiDiffSourceResolver.js'; + +suite('ScmMultiDiffSourceResolver', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('sorts resources by path', async () => { + const resources: ISCMResource[] = []; + const onDidChangeResources = disposables.add(new Emitter()); + const group = new class extends mock() { + override readonly id = 'changes'; + override readonly label = 'Changes'; + override readonly resources = resources; + override readonly onDidChangeResources = onDidChangeResources.event; + }(); + const provider = new class extends mock() { + override readonly id = 'scm0'; + override readonly providerId = 'scm0'; + override readonly rootUri = URI.file('/repository'); + override readonly groups = [group]; + override readonly onDidChangeResourceGroups = new Emitter().event; + }(); + const repository = new class extends mock() { + override readonly id = provider.id; + override readonly provider = provider; + }(); + const repositories = [repository]; + const onDidAddRepository = disposables.add(new Emitter()); + const scmService = new class extends mock() { + override readonly onDidAddRepository = onDidAddRepository.event; + override get repositories(): Iterable { return repositories; } + override get repositoryCount(): number { return repositories.length; } + override getRepository(idOrResource: string | URI): ISCMRepository | undefined { + return typeof idOrResource === 'string' ? repositories.find(r => r.id === idOrResource) : undefined; + } + }(); + + const activityService = new class extends mock() { + override readonly onDidChangeActivity = Event.None; + override getViewContainerActivities() { return []; } + }(); + const resolver = new ScmMultiDiffSourceResolver(scmService, activityService); + const sourceUri = ScmMultiDiffSourceResolver.getMultiDiffSourceUri(provider.rootUri.toString(), group.id); + const sourcePromise = resolver.resolveDiffSource(sourceUri); + + const createResource = (path: string) => new class extends mock() { + override readonly sourceUri = URI.file(path); + override readonly multiDiffEditorModifiedUri = this.sourceUri; + }(); + + resources.push( + createResource('/repository/githubServer.ts'), + createResource('/repository/env.ts'), + createResource('/repository/githubUri.ts'), + ); + onDidChangeResources.fire(); + + const source = await sourcePromise; + assert.deepStrictEqual( + source.resources.value.map(resource => resource.goToFileUri?.path), + [ + '/repository/env.ts', + '/repository/githubServer.ts', + '/repository/githubUri.ts', + ] + ); + }); +}); suite('ScmHistoryItemResolver', () => { From 7f7c7b1acfb29b99b909ae036ded65471b844b84 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Mon, 5 Oct 2026 03:38:39 -0700 Subject: [PATCH 45/49] chat: validate plugin marketplace source and cache paths (#339655) * chat: validate plugin source paths and skill copy destinations Keep marketplace identities and cache locations consistent, recover stale cache entries, and validate copied skill filenames against the destination filesystem. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: keep skill copying unchanged Keep the change set focused on plugin marketplace path validation while retaining the latest upstream skill installer behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: validate Windows cache names and decoded Git paths Validate computed and persisted cache components, check decoded source paths before sanitization, and filter invalid source and revision descriptors before catalog rendering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: validate generated marketplace ref cache segments Reject unsupported ref cache names during marketplace parsing instead of failing later during installation. Cover trailing dots and encoded filename-length boundaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentPluginRepositoryService.ts | 35 ++- .../contrib/chat/browser/pluginSources.ts | 28 +- .../common/plugins/marketplaceReference.ts | 87 +++++- .../plugins/pluginMarketplaceService.ts | 20 +- .../agentPluginRepositoryService.test.ts | 148 +++++++++- .../plugins/pluginInstallService.test.ts | 26 +- .../plugins/pluginMarketplaceService.test.ts | 264 +++++++++++++++++- .../plugins/strictKnownMarketplaces.test.ts | 21 ++ 8 files changed, 578 insertions(+), 51 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index 05ee58d5fb78..9a73d7fb5db1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -24,6 +24,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; +import { getPluginCacheUri, validatePluginCacheUri } from '../common/plugins/marketplaceReference.js'; import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; import { IPluginSource } from '../common/plugins/pluginSource.js'; import { IPluginGitService } from '../common/plugins/pluginGitService.js'; @@ -109,9 +110,9 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi return marketplace.localRepositoryUri; } - const indexed = this._marketplaceIndex.value.get(marketplace.canonicalId); - if (indexed?.repositoryUri && this._isSupportedIndexedRepositoryUri(indexed.repositoryUri)) { - return indexed.repositoryUri; + const indexed = this._getIndexedRepositoryUri(marketplace); + if (indexed) { + return indexed; } return this._getRepoCacheDirForReference(marketplace); @@ -146,11 +147,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } private async _ensureRemoteRepository(marketplace: IMarketplaceReference, options: IEnsureRepositoryOptions | undefined): Promise { - const storedIndexedRepoDir = this._marketplaceIndex.value.get(marketplace.canonicalId)?.repositoryUri; - const indexedRepoDir = storedIndexedRepoDir && this._isSupportedIndexedRepositoryUri(storedIndexedRepoDir) ? storedIndexedRepoDir : undefined; - if (storedIndexedRepoDir && !indexedRepoDir) { - this._removeMarketplaceIndex(marketplace); - } + const indexedRepoDir = this._getIndexedRepositoryUri(marketplace); const primaryRepoDir = this._getRepoCacheDirForReference(marketplace); const fallbackRepoDir = marketplace.ref ? undefined : this._getMarketplaceVariantCacheDir(marketplace, 'default'); const candidates: URI[] = []; @@ -264,8 +261,22 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } } - private _isSupportedIndexedRepositoryUri(repositoryUri: URI): boolean { - return isEqualOrParent(repositoryUri, this._cacheRoot) || isEqualOrParent(repositoryUri, this._legacyCacheRoot); + private _getIndexedRepositoryUri(marketplace: IMarketplaceReference): URI | undefined { + const indexed = this._marketplaceIndex.value.get(marketplace.canonicalId); + if (!indexed?.repositoryUri) { + return undefined; + } + let validationError: unknown; + for (const root of [this._cacheRoot, this._legacyCacheRoot]) { + try { + return validatePluginCacheUri(root, indexed.repositoryUri); + } catch (error) { + validationError = error; + } + } + this._logService.warn(`[AgentPluginRepositoryService] Discarding invalid cached marketplace location for ${marketplace.displayLabel}`, validationError); + this._removeMarketplaceIndex(marketplace); + return undefined; } /** @@ -417,12 +428,12 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } return this._getMarketplaceVariantCacheDir(reference, refSegment); } - return joinPath(this._cacheRoot, ...reference.cacheSegments); + return getPluginCacheUri(this._cacheRoot, reference.cacheSegments); } private _getMarketplaceVariantCacheDir(reference: IMarketplaceReference, variant: string): URI { const baseSegments = reference.ref ? reference.cacheSegments.slice(0, -1) : reference.cacheSegments; - return joinPath(this._cacheRoot, MARKETPLACE_VARIANT_CACHE_SEGMENT, ...baseSegments, variant); + return getPluginCacheUri(this._cacheRoot, [MARKETPLACE_VARIANT_CACHE_SEGMENT, ...baseSegments, variant]); } private _loadMarketplaceIndex(): Map { diff --git a/src/vs/workbench/contrib/chat/browser/pluginSources.ts b/src/vs/workbench/contrib/chat/browser/pluginSources.ts index 1729d36f935d..ef268c0187d0 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginSources.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginSources.ts @@ -22,6 +22,7 @@ import { IProgressService, ProgressLocation } from '../../../../platform/progres import { TerminalCapability, type ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalInstance, ITerminalService } from '../../terminal/browser/terminal.js'; import { IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; +import { getGitUrlCacheSegments, getPluginCacheUri, gitRevisionCacheSuffix } from '../common/plugins/marketplaceReference.js'; import { IGitHubPluginSource, IGitUrlPluginSource, IMarketplacePlugin, INpmPluginSource, IPipPluginSource, IPluginSourceDescriptor, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; import { IPluginSource } from '../common/plugins/pluginSource.js'; import { IPluginGitService } from '../common/plugins/pluginGitService.js'; @@ -34,16 +35,6 @@ function sanitizeCacheSegment(name: string): string { return name.replace(/[\\/:*?"<>|]/g, '_'); } -function gitRevisionCacheSuffix(ref?: string, sha?: string): string[] { - if (sha) { - return [`sha_${sanitizeCacheSegment(sha)}`]; - } - if (ref) { - return [`ref_${sanitizeCacheSegment(ref)}`]; - } - return []; -} - function shellEscapeArg(value: string): string { if (isWindows) { return `"${value.replace(/[`$"]/g, '`$&')}"`; @@ -276,7 +267,7 @@ export class GitHubPluginSource extends AbstractGitPluginSource { protected override _getRepoDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { const gh = descriptor as IGitHubPluginSource; const [owner, repo] = gh.repo.split('/'); - return joinPath(cacheRoot, 'github.com', owner, repo, ...gitRevisionCacheSuffix(gh.ref, gh.sha)); + return getPluginCacheUri(cacheRoot, ['github.com', owner, repo, ...gitRevisionCacheSuffix(gh.ref, gh.sha)]); } getLabel(descriptor: IPluginSourceDescriptor): string { @@ -319,8 +310,8 @@ export class GitUrlPluginSource extends AbstractGitPluginSource { /** Returns the cloned repository root (without sub-path). */ protected override _getRepoDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { const git = descriptor as IGitUrlPluginSource; - const segments = this._gitUrlCacheSegments(git.url, git.ref, git.sha); - return joinPath(cacheRoot, ...segments); + const segments = getGitUrlCacheSegments(git.url); + return getPluginCacheUri(cacheRoot, [...segments, ...gitRevisionCacheSuffix(git.ref, git.sha)]); } getLabel(descriptor: IPluginSourceDescriptor): string { @@ -336,17 +327,6 @@ export class GitUrlPluginSource extends AbstractGitPluginSource { return (descriptor as IGitUrlPluginSource).url; } - private _gitUrlCacheSegments(url: string, ref?: string, sha?: string): string[] { - try { - const parsed = URI.parse(url); - const authority = (parsed.authority || 'unknown').replace(/[\\/:*?"<>|]/g, '_').toLowerCase(); - const pathPart = parsed.path.replace(/^\/+/, '').replace(/\.git$/i, '').replace(/\/+$/g, ''); - const segments = pathPart.split('/').map(s => s.replace(/[\\/:*?"<>|]/g, '_')); - return [authority, ...segments, ...gitRevisionCacheSuffix(ref, sha)]; - } catch { - return ['git', url.replace(/[\\/:*?"<>|]/g, '_'), ...gitRevisionCacheSuffix(ref, sha)]; - } - } } // --------------------------------------------------------------------------- diff --git a/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts b/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts index f8e27b4300f4..53963d0222eb 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts @@ -4,7 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../base/common/uri.js'; +import { isValidBasename } from '../../../../../base/common/extpath.js'; import { ExtraKnownMarketplacesConfigDict, IExtraKnownMarketplaceConfigValue } from '../../../../../base/common/managedSettings.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { isWeb, isWindows } from '../../../../../base/common/platform.js'; +import { extUri, extUriIgnorePathCase, joinPath, normalizePath, removeTrailingPathSeparator } from '../../../../../base/common/resources.js'; +import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ChatConfiguration } from '../constants.js'; @@ -215,6 +220,9 @@ export function parseMarketplaceReference(value: string): IMarketplaceReference const owner = shorthandMatch[1]; const repo = shorthandMatch[2]; const ref = shorthandMatch[3]; + if (!isValidCacheSegments([owner, repo, ...getRefCacheSegments(ref)])) { + return undefined; + } return { rawValue, displayLabel: rawValue, @@ -266,6 +274,9 @@ function parseUriMarketplaceReference(rawValue: string): IMarketplaceReference | const ref = uri.fragment || undefined; const cloneUri = uri.fragment ? uri.with({ fragment: '' }) : uri; const sanitizedAuthority = sanitizePathSegment(uri.authority.toLowerCase()); + if (!isValidCacheSegments([sanitizedAuthority, ...getRefCacheSegments(ref)])) { + return undefined; + } const trimmedPath = uri.path.replace(/\/+/g, '/').replace(/\/+$/g, '').replace(/^\/+/, ''); // Host-only marketplace endpoint (e.g. `https://plugins.internal.example.com`). @@ -286,7 +297,13 @@ function parseUriMarketplaceReference(rawValue: string): IMarketplaceReference | const gitSuffix = '.git'; const pathHasGitSuffix = trimmedPath.toLowerCase().endsWith(gitSuffix); const pathWithoutGit = pathHasGitSuffix ? trimmedPath.slice(0, trimmedPath.length - gitSuffix.length) : trimmedPath; + if (hasDotSegments(trimmedPath) || hasDotSegments(pathWithoutGit)) { + return undefined; + } const pathSegments = pathWithoutGit.split('/').map(sanitizePathSegment); + if (!isValidCacheSegments(pathSegments)) { + return undefined; + } // Always normalize the canonical path to include .git so that URLs with and without the suffix deduplicate. const canonicalPath = pathHasGitSuffix ? trimmedPath.toLowerCase() : `${trimmedPath.toLowerCase()}${gitSuffix}`; @@ -331,7 +348,14 @@ function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | } const pathWithoutGit = pathWithGit.slice(0, -gitSuffix.length); + if (hasDotSegments(pathWithGit) || hasDotSegments(pathWithoutGit)) { + return undefined; + } const pathSegments = pathWithoutGit.split('/').map(sanitizePathSegment); + const cacheSegments = [sanitizePathSegment(authority.toLowerCase()), ...pathSegments, ...getRefCacheSegments(ref)]; + if (!isValidCacheSegments(cacheSegments)) { + return undefined; + } const githubRepo = extractGitHubRepo(authority, pathWithoutGit); // Normalize git@github.com:/.git to the same canonical id the @@ -349,7 +373,7 @@ function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | displayLabel: rawValue, cloneUrl: `${match[1]}@${authority}:${pathWithGit}`, canonicalId, - cacheSegments: [sanitizePathSegment(authority.toLowerCase()), ...pathSegments, ...getRefCacheSegments(ref)], + cacheSegments, kind: MarketplaceReferenceKind.GitUri, ref, githubRepo, @@ -361,7 +385,7 @@ function extractGitHubRepo(authority: string, pathWithoutGit: string): string | return undefined; } const parts = pathWithoutGit.split('/'); - if (parts.length >= 2 && parts[0] && parts[1]) { + if (parts.length === 2 && parts[0] && parts[1]) { return `${parts[0]}/${parts[1]}`; } return undefined; @@ -382,3 +406,62 @@ function getRefCacheSegments(ref: string | undefined): string[] { function sanitizePathSegment(value: string): string { return value.replace(/[\\/:*?"<>|]/g, '_'); } + +function hasDotSegments(path: string): boolean { + return path.split(/[\\/]/).some(segment => segment === '.' || segment === '..'); +} + +function isValidCacheSegments(segments: readonly string[], windows = isWindows && !isWeb): boolean { + return segments.every(segment => !hasDotSegments(segment) + && (!windows || !segment || (isValidBasename(segment, true) && !/[\u0000-\u001f\u007f]/.test(segment)))); +} + +export function gitRevisionCacheSuffix(ref?: string, sha?: string): string[] { + const segments = sha ? [`sha_${sanitizePathSegment(sha)}`] : ref ? [`ref_${sanitizePathSegment(ref)}`] : []; + if (!isValidCacheSegments(segments)) { + throw new Error(localize('invalidPluginCacheSegments', "Invalid plugin cache path '{0}'.", segments.join('/'))); + } + return segments; +} + +export function getGitUrlCacheSegments(url: string): string[] { + let path: string; + let segments: string[]; + try { + const parsed = URI.parse(url); + path = parsed.path; + const authority = sanitizePathSegment(parsed.authority || 'unknown').toLowerCase(); + const pathPart = path.replace(/^\/+/, '').replace(/\.git$/i, '').replace(/\/+$/g, ''); + segments = [authority, ...pathPart.split('/').map(sanitizePathSegment)]; + } catch { + path = /^[^@\s]+@[^:\s]+:(?.+)$/.exec(url)?.groups?.path ?? url; + segments = ['git', sanitizePathSegment(url)]; + } + if (hasDotSegments(path) || !isValidCacheSegments(segments)) { + throw new Error(localize('invalidPluginCacheSegments', "Invalid plugin cache path '{0}'.", url)); + } + return segments; +} + +/** Resolves remote plugin cache segments without permitting traversal or the cache root itself. */ +export function getPluginCacheUri(cacheRoot: URI, segments: readonly string[]): URI { + if (!isValidCacheSegments(segments, cacheRoot.scheme === Schemas.file && isWindows)) { + throw new Error(localize('invalidPluginCacheSegments', "Invalid plugin cache path '{0}'.", segments.join('/'))); + } + return validatePluginCacheUri(cacheRoot, joinPath(cacheRoot, ...segments)); +} + +/** Validates computed and persisted remote repository locations before filesystem access. */ +export function validatePluginCacheUri(cacheRoot: URI, resource: URI): URI { + // Revived URI caches must not override the structural path we validate. + const directory = removeTrailingPathSeparator(normalizePath(URI.from(resource))); + const root = removeTrailingPathSeparator(normalizePath(URI.from(cacheRoot))); + const windows = root.scheme === Schemas.file && isWindows; + const comparer = windows ? extUriIgnorePathCase : extUri; + const relative = comparer.relativePath(root, directory); + if (!comparer.isEqualOrParent(directory, root) || comparer.isEqual(directory, root) + || (windows && (relative === undefined || !isValidCacheSegments(relative.split('/'), true)))) { + throw new Error(localize('invalidPluginCachePath', "Invalid plugin cache path '{0}'.", resource.toString())); + } + return directory; +} diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 574afb2e6c86..5b823aba64e6 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -35,7 +35,7 @@ import { FileBackedInstalledPluginsStore, IStoredInstalledPlugin } from './fileB import { IWorkspacePluginSettingsService } from './workspacePluginSettingsService.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { readAgentPluginManifest } from '../../../../../platform/agentPlugins/common/agentPluginParser.js'; -import { type IMarketplaceReference, deduplicateMarketplaceReferences, MarketplaceReferenceKind, parseMarketplaceObjectEntry, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; +import { type IMarketplaceReference, deduplicateMarketplaceReferences, getGitUrlCacheSegments, gitRevisionCacheSuffix, MarketplaceReferenceKind, parseMarketplaceObjectEntry, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; import { getStrictKnownMarketplaces, isMarketplaceReferenceAllowed } from './strictKnownMarketplaces.js'; // Re-export marketplace reference types for downstream consumers. @@ -273,7 +273,8 @@ const SINGLE_PLUGIN_MANIFEST_DEFINITIONS: { type: MarketplaceType; path: string ]; const GITHUB_MARKETPLACE_CACHE_TTL_MS = 8 * 60 * 60 * 1000; -const GITHUB_MARKETPLACE_CACHE_STORAGE_KEY = 'chat.plugins.marketplaces.githubCache.v1'; +// Ignore catalogs persisted before Git source path validation. +const GITHUB_MARKETPLACE_CACHE_STORAGE_KEY = 'chat.plugins.marketplaces.githubCache.v2'; /** Interval between periodic plugin update checks (24 hours). */ const PLUGIN_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -1273,6 +1274,12 @@ export function parsePluginSource( logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'path' must be a string when provided`); return undefined; } + try { + gitRevisionCacheSuffix(rawSource.ref, rawSource.sha); + } catch (error) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source revision contains invalid path segments`, error); + return undefined; + } return { kind: PluginSourceKind.GitHub, repo: rawSource.repo, @@ -1308,6 +1315,13 @@ export function parsePluginSource( logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source 'path' must be a string when provided`); return undefined; } + try { + getGitUrlCacheSegments(rawSource.url); + gitRevisionCacheSuffix(rawSource.ref, rawSource.sha); + } catch (error) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': ${rawSource.source} source URL or revision contains invalid path segments`, error); + return undefined; + } return { kind: PluginSourceKind.GitUrl, url: rawSource.url, @@ -1363,7 +1377,7 @@ function isOptionalGitSha(value: unknown): value is string | undefined { } function isValidGitHubRepo(repo: string): boolean { - return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo); + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !!parseMarketplaceReference(repo); } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index 2da2b9f1598a..ec82ee986819 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { isWindows } from '../../../../../../base/common/platform.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; @@ -19,7 +20,7 @@ import { IProgressService } from '../../../../../../platform/progress/common/pro import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { AgentPluginRepositoryService } from '../../../browser/agentPluginRepositoryService.js'; -import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IPluginSourceDescriptor, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; import { IPluginGitService } from '../../../common/plugins/pluginGitService.js'; suite('AgentPluginRepositoryService', () => { @@ -83,6 +84,7 @@ suite('AgentPluginRepositoryService', () => { pluginGitStub?: Partial, fileServiceStub?: Partial, storageService?: IStorageService, + logService?: ILogService, ): AgentPluginRepositoryService { const instantiationService = store.add(new TestInstantiationService()); @@ -106,7 +108,7 @@ suite('AgentPluginRepositoryService', () => { instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, fileService); - instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ILogService, logService ?? new NullLogService()); instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); instantiationService.stub(IPluginGitService, stubPluginGit({ ...pluginGitStub, @@ -117,6 +119,148 @@ suite('AgentPluginRepositoryService', () => { return instantiationService.createInstance(AgentPluginRepositoryService); } + for (const [name, cacheSegments] of [ + ['outside cache', ['host', 'a', '..', '..', '..', '..', 'outside']], + ['cache root', ['host', '..']], + ['empty cache path', []], + ] as const) { + test(`rejects marketplace cache destination ${name} before filesystem access`, async () => { + const operations: string[] = []; + const service = createService(async () => { + operations.push('exists'); + return false; + }, undefined, { + cloneRepository: async () => { operations.push('clone'); }, + }, { + createFolder: async resource => { operations.push('createFolder'); return directoryStat(resource); }, + }); + const reference = { ...createPlugin('microsoft/vscode', '').marketplaceReference, cacheSegments }; + await assert.rejects(service.ensureRepository(reference), /Invalid plugin cache path/); + assert.deepStrictEqual(operations, []); + }); + } + + for (const [name, cacheSegments] of [ + ['repository segments', ['host', '..', '..', '..', 'ref_main']], + ['revision segment', ['host', '..']], + ] as const) { + test(`rejects ref-specific marketplace cache traversal in ${name}`, async () => { + const operations: string[] = []; + const service = createService(async () => { + operations.push('exists'); + return false; + }, undefined, { + cloneRepository: async () => { operations.push('clone'); }, + }, { + createFolder: async resource => { operations.push('createFolder'); return directoryStat(resource); }, + }); + const reference = { ...createPlugin('microsoft/vscode#main', '').marketplaceReference, cacheSegments }; + await assert.rejects(service.ensureRepository(reference), /Invalid plugin cache path/); + assert.deepStrictEqual(operations, []); + }); + } + + for (const [name, path] of [ + ['outside cache', '/outside'], + ['cache root', '/cache/agentPlugins'], + ['unnormalized escape', '/cache/agentPlugins/host/../../outside'], + ['cache root with trailing separator', '/cache/agentPlugins/'], + ['cache root after normalization', '/cache/agentPlugins/host/../'], + ...(isWindows ? [ + ['Windows parent alias', '/cache/agentPlugins/host/.. /repo'], + ['Windows reserved name', '/cache/agentPlugins/host/NUL/repo'], + ] : []), + ]) { + test(`recovers from indexed marketplace destination ${name} without accessing it`, async () => { + const storage = store.add(new InMemoryStorageService()); + storage.store('chat.plugins.marketplaces.index.v1', JSON.stringify({ + 'github:microsoft/vscode': { repositoryUri: URI.file(path) }, + }), StorageScope.APPLICATION, StorageTarget.MACHINE); + const warnings: (string | Error)[] = []; + const logService = store.add(new class extends NullLogService { + override warn(message: string | Error): void { warnings.push(message); } + }()); + const operations: { operation: string; path: string }[] = []; + const service = createService(async resource => { + operations.push({ operation: 'exists', path: resource.path }); + return false; + }, undefined, { + cloneRepository: async (_url, directory) => { operations.push({ operation: 'clone', path: directory.path }); }, + }, { + createFolder: async directory => { operations.push({ operation: 'createFolder', path: directory.path }); return directoryStat(directory); }, + }, storage, logService); + const reference = createPlugin('microsoft/vscode', '').marketplaceReference; + const result = service.ensureRepository(reference); + await assert.doesNotReject(result); + const directory = await result; + const expected = '/cache/agentPlugins/github.com/microsoft/vscode'; + const restarted = createService(undefined, undefined, undefined, undefined, storage); + assert.deepStrictEqual({ + directory: directory.path, + operations, + restoredDirectory: restarted.getRepositoryUri(reference).path, + warnings: warnings.length, + }, { + directory: expected, + operations: [ + { operation: 'exists', path: expected }, + { operation: 'createFolder', path: '/cache/agentPlugins/github.com/microsoft' }, + { operation: 'clone', path: expected }, + ], + restoredDirectory: expected, + warnings: 1, + }); + }); + } + + test('uses structural URI components for indexed marketplace filesystem paths', async () => { + const expected = URI.file('/cache/agentPlugins/github.com/microsoft/vscode'); + const storage = store.add(new InMemoryStorageService()); + storage.store('chat.plugins.marketplaces.index.v1', JSON.stringify({ + 'github:microsoft/vscode': { + repositoryUri: { ...expected.toJSON(), fsPath: URI.file('/outside').fsPath, _sep: isWindows ? 1 : undefined }, + }, + }), StorageScope.APPLICATION, StorageTarget.MACHINE); + const filesystemPaths: string[] = []; + const service = createService(async resource => { + filesystemPaths.push(resource.fsPath); + return false; + }, undefined, { + cloneRepository: async (_url, directory) => { filesystemPaths.push(directory.fsPath); }, + }, undefined, storage); + const directory = await service.ensureRepository(createPlugin('microsoft/vscode', '').marketplaceReference); + assert.deepStrictEqual({ directory: directory.fsPath, filesystemPaths }, { + directory: expected.fsPath, + filesystemPaths: [expected.fsPath, expected.fsPath], + }); + }); + + const unsafeSources: { name: string; descriptor: IPluginSourceDescriptor }[] = [ + { name: 'Git URL escape', descriptor: { kind: PluginSourceKind.GitUrl, url: 'http://example.com/a/../../../../outside' } }, + { name: 'encoded Git URL escape', descriptor: { kind: PluginSourceKind.GitUrl, url: 'http://example.com/a/%2e%2e/%2e%2e/%2e%2e/%2e%2e/outside' } }, + { name: 'Git URL dot segment inside cache', descriptor: { kind: PluginSourceKind.GitUrl, url: 'http://example.com/team/../other.git' } }, + { name: 'Git URL suffix', descriptor: { kind: PluginSourceKind.GitUrl, url: 'http://example.com/...git' } }, + { name: 'GitHub cache root', descriptor: { kind: PluginSourceKind.GitHub, repo: '../..' } }, + { name: 'backslash Git URL', descriptor: { kind: PluginSourceKind.GitUrl, url: String.raw`https://example.com/a\..\b.git` } }, + { name: 'encoded backslash Git URL', descriptor: { kind: PluginSourceKind.GitUrl, url: 'https://example.com/a%5c..%5cb.git' } }, + ]; + for (const { name, descriptor } of unsafeSources) { + test(`rejects plugin source cache traversal: ${name}`, async () => { + const operations: string[] = []; + const service = createService(async () => { + operations.push('exists'); + return false; + }, undefined, { + cloneRepository: async () => { operations.push('clone'); }, + }, { + createFolder: async resource => { operations.push('createFolder'); return directoryStat(resource); }, + }); + const plugin = { ...createPlugin('microsoft/vscode', ''), sourceDescriptor: descriptor }; + await assert.rejects(service.ensurePluginSource(plugin), /Invalid plugin cache path/); + assert.deepStrictEqual(operations, []); + }); + } + test('uses cacheSegments path for GitHub shorthand plugin references', () => { const service = createService(); const plugin = createPlugin('microsoft/vscode', 'plugins/myPlugin'); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts index 3d995bbc26d2..c43d02f2087c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -29,6 +29,7 @@ import { ContributionEnablementState } from '../../../common/enablement.js'; import { AgentPluginEnablementService, IAgentPluginEnablementService } from '../../../common/plugins/agentPluginEnablement.js'; import { IFetchMarketplacePluginsOptions, IMarketplaceInstalledPlugin, IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, IPluginSourceDescriptor, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; import { IPluginSource } from '../../../common/plugins/pluginSource.js'; +import { isMarketplaceReferenceAllowed, StrictKnownMarketplaces } from '../../../common/plugins/strictKnownMarketplaces.js'; suite('PluginInstallService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -83,6 +84,7 @@ suite('PluginInstallService', () => { marketplaceTrusted: boolean; /** Whether the strict-marketplace enterprise policy is active */ strictMarketplacePolicyActive?: boolean; + strictMarketplaces?: StrictKnownMarketplaces; installedPlugins: IMarketplaceInstalledPlugin[]; durablePluginUris: URI[]; removedPluginUris: string[]; @@ -356,8 +358,8 @@ suite('PluginInstallService', () => { installedPlugins.set(state.installedPlugins, undefined); return true; }, - isMarketplaceTrusted: () => state.marketplaceTrusted, - isStrictMarketplacePolicyActive: () => state.strictMarketplacePolicyActive ?? false, + isMarketplaceTrusted: (ref: IMarketplaceReference) => state.strictMarketplaces !== undefined ? isMarketplaceReferenceAllowed(state.strictMarketplaces, ref) : state.marketplaceTrusted, + isStrictMarketplacePolicyActive: () => state.strictMarketplaces !== undefined || (state.strictMarketplacePolicyActive ?? false), isMarketplaceAutoUpdateEnabled: (ref: IMarketplaceReference) => state.autoUpdateByMarketplace.get(ref.canonicalId) ?? true, fetchMarketplacePlugins: async (_token: CancellationToken, marketplaceIds?: ReadonlySet, options?: IFetchMarketplacePluginsOptions) => { state.fetchMarketplaceCalls.push([...marketplaceIds ?? []]); @@ -1448,6 +1450,26 @@ suite('PluginInstallService', () => { suite('installPluginFromSource', () => { + test('rejects marketplace traversal before cloning a pinned source', async () => { + const { service, state } = createService({ + strictMarketplaces: [{ source: 'github', repo: 'microsoft/vscode', ref: 'marketplace' }], + }); + const result = await service.installPluginFromSource('https://github.com/microsoft/vscode/../../example/unapproved.git#marketplace'); + assert.deepStrictEqual({ + success: result.success, + invalidSource: result.message?.includes('is not a valid plugin source'), + cloned: state.ensurePluginSourceDescriptors, + installed: state.addedPlugins, + trusted: state.trustedMarketplaces, + }, { + success: false, + invalidSource: true, + cloned: [], + installed: [], + trusted: [], + }); + }); + test('keeps legacy source handling for repository-root plugins', async () => { const { service, state } = createService({ singlePluginManifestResult: createPlugin({ diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index b0973f621750..72f573beaa9d 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -10,9 +10,10 @@ import { bufferToStream, VSBuffer } from '../../../../../../base/common/buffer.j import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../../base/common/network.js'; import { observableValue } from '../../../../../../base/common/observable.js'; -import { isWeb } from '../../../../../../base/common/platform.js'; -import { joinPath } from '../../../../../../base/common/resources.js'; +import { isWeb, isWindows } from '../../../../../../base/common/platform.js'; +import { basename, joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AGENT_PLUGIN_SCHEMA } from '../../../../../../platform/agentPlugins/common/agentPluginParser.js'; @@ -29,6 +30,7 @@ import { IEnvironmentService } from '../../../../../../platform/environment/comm import { AutoUpdateConfigurationValue, IExtensionsWorkbenchService } from '../../../../extensions/common/extensions.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPluginRepositoryService.js'; +import { getPluginCacheUri, parseMarketplaceObjectEntry, validatePluginCacheUri } from '../../../common/plugins/marketplaceReference.js'; import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, extraKnownMarketplacesToConfigDict, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource, readConfiguredMarketplaces } from '../../../common/plugins/pluginMarketplaceService.js'; import { IWorkspacePluginSettingsService } from '../../../common/plugins/workspacePluginSettingsService.js'; @@ -59,7 +61,155 @@ function stubMeteredConnectionService(instantiationService: TestInstantiationSer } suite('PluginMarketplaceService', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + for (const [name, value] of [ + ['GitHub URL', 'https://github.com/microsoft/vscode/../../example/unapproved.git'], + ['GitHub URL with ref', 'https://github.com/microsoft/vscode/../../example/unapproved.git#marketplace'], + ['encoded parent', 'https://github.com/microsoft/vscode/%2e%2e/%2E%2E/example/unapproved.git'], + ['mixed encoded parent', 'https://github.com/microsoft/vscode/.%2e/%2e./example/unapproved.git'], + ['encoded separator', 'https://github.com/microsoft/vscode/..%2f..%2fexample/unapproved.git'], + ['encoded backslash', 'https://github.com/microsoft/vscode/..%5c..%5cexample/unapproved.git'], + ['backslash', String.raw`https://github.com/microsoft/vscode/..\..\example/unapproved.git`], + ['current directory', 'https://example.com/team/./repo.git'], + ['encoded current directory', 'https://example.com/team/%2e/repo.git'], + ['cache escape', 'http://example.com/a/../../../../outside'], + ['dot after removing git suffix', 'https://example.com/team/..git'], + ['parent after removing git suffix', 'https://example.com/team/...git'], + ['SSH URL', 'ssh://git@example.com/team/../../outside.git'], + ['SCP URL', 'git@example.com:team/../../outside.git'], + ['SCP git suffix', 'git@example.com:team/...git'], + ['shorthand owner', '../repo'], + ['shorthand repository', 'owner/..'], + ['shorthand current owner', './repo'], + ['shorthand current repository', 'owner/.'], + ]) { + test(`rejects marketplace traversal: ${name}`, () => { + assert.deepStrictEqual({ + invalid: parseMarketplaceReference(value), + allowed: parseMarketplaceReference('https://github.com/microsoft/vscode.git#marketplace')?.cloneUrl, + }, { + invalid: undefined, + allowed: 'https://github.com/microsoft/vscode.git', + }); + }); + } + + for (const path of ['/cache/agentPlugins/', '/cache/agentPlugins/host/../']) { + test(`rejects an equivalent plugin cache root: ${path}`, () => { + assert.throws(() => validatePluginCacheUri(URI.file('/cache/agentPlugins'), URI.file(path)), /Invalid plugin cache path/); + }); + } + + test('plugin cache validation discards a revived filesystem path', () => { + const expected = URI.file('/cache/agentPlugins/github.com/microsoft/vscode'); + const stored = { ...expected.toJSON(), fsPath: URI.file('/outside').fsPath, _sep: isWindows ? 1 : undefined }; + const directory = validatePluginCacheUri(URI.file('/cache/agentPlugins'), URI.revive(stored)); + assert.deepStrictEqual({ path: directory.path, fsPath: directory.fsPath }, { path: expected.path, fsPath: expected.fsPath }); + }); + + (isWindows ? test : test.skip)('plugin cache validation respects Windows path casing', () => { + const root = URI.file(String.raw`C:\cache\agentPlugins`); + assert.doesNotThrow(() => validatePluginCacheUri(root, URI.file(String.raw`c:\CACHE\agentPlugins\github.com\owner\repo`))); + assert.throws(() => validatePluginCacheUri(root, URI.file('c:\\CACHE\\agentPlugins\\')), /Invalid plugin cache path/); + }); + + for (const segment of ['.. ', 'repo.', 'NUL', 'data:stream', 'file\u0001']) { + (isWindows ? test : test.skip)(`rejects Windows-invalid plugin cache component ${JSON.stringify(segment)}`, () => { + const root = URI.file('/cache/agentPlugins'); + const segments = ['host', segment, 'repo']; + assert.throws(() => getPluginCacheUri(root, segments), /Invalid plugin cache path/); + assert.throws(() => validatePluginCacheUri(root, joinPath(root, ...segments)), /Invalid plugin cache path/); + const virtualRoot = root.with({ scheme: Schemas.inMemory }); + assert.strictEqual(getPluginCacheUri(virtualRoot, segments).path, `${virtualRoot.path}/host/${segment}/repo`); + }); + } + + for (const value of [ + 'https://example.com/a/%2e%2e%20/repo.git', + 'https://example.com/a/repo..git', + 'owner/NUL', + 'git@example.com:a/repo..git', + ]) { + (isWindows && !isWeb ? test : test.skip)(`rejects Windows-invalid marketplace cache path ${value}`, () => { + assert.deepStrictEqual({ + invalid: parseMarketplaceReference(value), + allowed: parseMarketplaceReference('owner/repo')?.cloneUrl, + }, { invalid: undefined, allowed: 'https://github.com/owner/repo.git' }); + }); + } + + for (const [kind, source] of [ + ['shorthand', 'owner/repo'], + ['host-only URL', 'https://example.com'], + ['GitHub URL', 'https://github.com/owner/repo.git'], + ['Git URL', 'https://example.com/owner/repo.git'], + ['SSH URL', 'ssh://git@example.com/owner/repo.git'], + ]) { + (isWindows && !isWeb ? test : test.skip)(`validates Windows marketplace ref cache segments for ${kind}`, () => { + const invalidRefs = ['branch.', 'r'.repeat(252), 'r'.repeat(256), `${'r'.repeat(249)}/`]; + const allowedRefs = ['release/v1', 'r'.repeat(251), `${'r'.repeat(248)}/`]; + assert.deepStrictEqual({ + invalid: invalidRefs.map(ref => parseMarketplaceReference(`${source}#${ref}`)?.ref), + allowed: allowedRefs.map(ref => { + const parsed = parseMarketplaceReference(`${source}#${ref}`); + return parsed && basename(getPluginCacheUri(URI.file('/cache/agentPlugins'), parsed.cacheSegments)); + }), + }, { + invalid: invalidRefs.map(() => undefined), + allowed: allowedRefs.map(ref => `ref_${encodeURIComponent(ref)}`), + }); + }); + } + + test('workspace marketplace traversal cannot replace an allowed clone target', async () => { + const allowed = 'https://github.com/microsoft/vscode.git'; + const workspaceReference = parseMarketplaceObjectEntry({ + name: 'workspace-tools', + source: { source: 'git', url: 'https://github.com/microsoft/vscode/../../example/unapproved.git' }, + }); + const clones: string[] = []; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [ChatConfiguration.PluginMarketplaces]: [allowed], + [ChatConfiguration.PluginsEnabled]: true, + [ChatConfiguration.StrictMarketplaces]: [{ source: 'github', repo: 'microsoft/vscode' }], + })); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') }); + instantiationService.stub(IFileService, { + readFile: async (resource: URI) => { + const value = VSBuffer.fromString('{"plugins":[]}'); + return { resource, name: basename(resource), value, size: value.byteLength, mtime: 0, ctime: 0, etag: '', readonly: false, locked: false, executable: false }; + }, + createWatcher: () => ({ onDidChange: Event.None, dispose: () => { } }), + }); + instantiationService.stub(IAgentPluginRepositoryService, { + agentPluginsHome: URI.file('/agent-plugins'), + ensureRepository: async (reference: IMarketplaceReference) => { + clones.push(reference.cloneUrl); + return URI.file('/cache/marketplace'); + }, + }); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IRequestService, { + request: async () => { throw new Error('Unexpected HTTP request'); }, + }); + instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); + instantiationService.stub(IWorkspacePluginSettingsService, { + extraMarketplaces: observableValue('test.extraMarketplaces', workspaceReference ? [{ name: 'workspace-tools', reference: workspaceReference }] : []), + enabledPlugins: observableValue('test.enabledPlugins', new Map()), + }); + instantiationService.stub(IWorkspaceTrustManagementService, { + isWorkspaceTrusted: () => true, + onDidChangeTrust: Event.None, + }); + instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'off' }); + stubMeteredConnectionService(instantiationService); + + const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); + await service.fetchMarketplacePlugins(CancellationToken.None); + assert.deepStrictEqual(clones, [allowed]); + }); test('parses GitHub shorthand marketplace', () => { const parsed = parseMarketplaceReference('microsoft/vscode'); @@ -615,6 +765,21 @@ suite('PluginMarketplaceService - Agent Plugin direct install probes', () => { })); } + test('skips invalid Git entries without hiding valid marketplace plugins', async () => { + const fileService = new ProbeFileService(); + const repoDir = URI.file('/repos/catalog'); + fileService.files.set(joinPath(repoDir, 'marketplace.json').toString(), JSON.stringify({ + plugins: [ + { name: 'invalid-github', source: { source: 'github', repo: 'owner/..' } }, + { name: 'invalid-url', source: { source: 'url', url: 'https://example.com/a/../b.git' } }, + { name: 'valid', source: { source: 'github', repo: 'owner/plugin' } }, + ], + })); + const service = createService(fileService); + const plugins = await service.readPluginsFromDirectory(repoDir, parseMarketplaceReference('owner/catalog')!); + assert.deepStrictEqual(plugins.map(plugin => plugin.name), ['valid']); + }); + test('reads a Git direct-source manifest with a compatible schema revision', async () => { const fileService = new ProbeFileService(); const repoDir = URI.file('/repos/compatible'); @@ -1299,8 +1464,8 @@ suite('PluginMarketplaceService - hydration after restart', () => { }; } - function storeMarketplaceCache(storageService: InMemoryStorageService, marketplaceReference: IMarketplaceReference, plugin: IMarketplacePlugin): void { - storageService.store('chat.plugins.marketplaces.githubCache.v1', JSON.stringify({ + function storeMarketplaceCache(storageService: InMemoryStorageService, marketplaceReference: IMarketplaceReference, plugin: IMarketplacePlugin, key = 'chat.plugins.marketplaces.githubCache.v2'): void { + storageService.store(key, JSON.stringify({ [marketplaceReference.canonicalId]: { plugins: [plugin], expiresAt: Date.now() + 60_000, @@ -1309,6 +1474,54 @@ suite('PluginMarketplaceService - hydration after restart', () => { }), StorageScope.APPLICATION, StorageTarget.MACHINE); } + test('refreshes marketplace catalogs cached before source validation', async () => { + const storageService = store.add(new InMemoryStorageService()); + const reference = parseMarketplaceReference('owner/catalog')!; + storeMarketplaceCache(storageService, reference, { + ...makeAzurePlugin(reference), + name: 'unsafe', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/..' }, + }, 'chat.plugins.marketplaces.githubCache.v1'); + let requests = 0; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [ChatConfiguration.PluginMarketplaces]: [reference.rawValue], + [ChatConfiguration.PluginsEnabled]: true, + })); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') }); + instantiationService.stub(IFileService, new TestFileService() as unknown as IFileService); + instantiationService.stub(IAgentPluginRepositoryService, createPluginRepositoryStub()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IRequestService, { + request: async () => { + requests++; + return { + res: { headers: {}, statusCode: 200 }, + stream: bufferToStream(VSBuffer.fromString(JSON.stringify({ + plugins: [ + { name: 'unsafe', source: { source: 'github', repo: 'owner/..' } }, + { name: 'safe', source: { source: 'github', repo: 'owner/plugin' } }, + ], + }))), + }; + }, + }); + instantiationService.stub(IStorageService, storageService); + instantiationService.stub(IWorkspacePluginSettingsService, { + extraMarketplaces: observableValue('test.extraMarketplaces', []), + enabledPlugins: observableValue('test.enabledPlugins', new Map()), + }); + instantiationService.stub(IWorkspaceTrustManagementService, { + isWorkspaceTrusted: () => true, + onDidChangeTrust: Event.None, + }); + instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'off' }); + stubMeteredConnectionService(instantiationService); + const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); + const plugins = await service.fetchMarketplacePlugins(CancellationToken.None); + assert.deepStrictEqual({ requests, plugins: plugins.map(plugin => plugin.name) }, { requests: 1, plugins: ['safe'] }); + }); + test('hydrates a github-sourced plugin from installed.json name and marketplace cache after restart', async () => { // Simulates: user installs the "azure" plugin from the // "github/awesome-copilot#marketplace" marketplace (fetched via HTTP, never @@ -1583,7 +1796,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { }); suite('parsePluginSource', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); const logContext = { pluginName: 'test', @@ -1591,6 +1804,45 @@ suite('parsePluginSource', () => { logPrefix: '[test]', }; + for (const [name, source] of [ + ['GitHub parent', { source: 'github', repo: 'owner/..' }], + ['GitHub current directory', { source: 'github', repo: './repo' }], + ['Git URL parent', { source: 'url', url: 'https://example.com/a/../b.git' }], + ['encoded Git URL parent', { source: 'url', url: 'https://example.com/a/%2e%2e/b.git' }], + ['Git URL suffix', { source: 'url', url: 'https://example.com/...git' }], + ['SCP parent', { source: 'url', url: 'git@example.com:a/../../b.git' }], + ['git-subdir parent', { source: 'git-subdir', url: 'https://example.com/a/../b', path: 'plugins/tool' }], + ['backslash parent', { source: 'url', url: String.raw`https://example.com/a\..\b.git` }], + ['encoded backslash parent', { source: 'url', url: 'https://example.com/a%5c..%5cb.git' }], + ['encoded backslash and dots', { source: 'url', url: 'https://example.com/a%5c%2e%2e%5cb.git' }], + ['git-subdir backslash parent', { source: 'git-subdir', url: String.raw`https://example.com/a\..\b`, path: 'plugins/tool' }], + ] as const) { + test(`skips unsafe Git source with a warning: ${name}`, () => { + const warnings: (string | Error)[] = []; + const logService = store.add(new class extends NullLogService { + override warn(message: string | Error): void { warnings.push(message); } + }()); + const result = parsePluginSource(source, undefined, { ...logContext, logService }); + assert.deepStrictEqual({ result, warnings: warnings.length }, { result: undefined, warnings: 1 }); + }); + } + + for (const [name, source] of [ + ['GitHub reserved name', { source: 'github', repo: 'owner/NUL' }], + ['Git URL trailing dot', { source: 'url', url: 'https://example.com/a/repo..git' }], + ['GitHub revision', { source: 'github', repo: 'owner/repo', ref: 'branch.' }], + ['Git URL revision', { source: 'url', url: 'https://example.com/a/repo.git', ref: 'branch ' }], + ] as const) { + (isWindows && !isWeb ? test : test.skip)(`skips Windows-invalid Git source with a warning: ${name}`, () => { + const warnings: (string | Error)[] = []; + const logService = store.add(new class extends NullLogService { + override warn(message: string | Error): void { warnings.push(message); } + }()); + const result = parsePluginSource(source, undefined, { ...logContext, logService }); + assert.deepStrictEqual({ result, warnings: warnings.length }, { result: undefined, warnings: 1 }); + }); + } + test('parses string source as RelativePath', () => { const result = parsePluginSource('./my-plugin', undefined, logContext); assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'my-plugin' }); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts index e29c4bb09aac..e72c21d9af1c 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts @@ -50,6 +50,27 @@ suite('strictKnownMarketplaces', () => { assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('microsoft/other-repo')), false); }); + for (const revision of [undefined, 'marketplace']) { + test(`marketplace traversal cannot inherit an allowed identity (${revision ?? 'default ref'})`, () => { + const suffix = revision ? `#${revision}` : ''; + const allowlist: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'microsoft/vscode', ref: revision }]; + const candidate = parseMarketplaceReference(`https://github.com/microsoft/vscode/../../example/unapproved.git${suffix}`); + assert.deepStrictEqual({ + crafted: !!candidate && isMarketplaceReferenceAllowed(allowlist, candidate), + direct: isMarketplaceReferenceAllowed(allowlist, ref(`example/unapproved${suffix}`)), + approved: isMarketplaceReferenceAllowed(allowlist, ref(`microsoft/vscode${suffix}`)), + }, { crafted: false, direct: false, approved: true }); + }); + } + + test('a GitHub repository prefix is not a marketplace identity', () => { + const allowlist: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'microsoft/vscode' }]; + assert.deepStrictEqual([ + 'https://github.com/microsoft/vscode/another.git', + 'git@github.com:microsoft/vscode/another.git', + ].map(value => isMarketplaceReferenceAllowed(allowlist, ref(value))), [false, false]); + }); + test('github entry ref must match exactly (tri-state)', () => { const pinned: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'owner/repo', ref: 'main' }]; assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('owner/repo#main')), true); From 5a44f782c3291d1ec547bae154bdb50d63435a1e Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:39:46 +0000 Subject: [PATCH 46/49] Bump version to 1.142.0 (#339650) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- extensions/copilot/package-lock.json | 6 +++--- extensions/copilot/package.json | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index d3a7c08ee7fb..99a95abe5c57 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -1,12 +1,12 @@ { "name": "copilot-chat", - "version": "0.69.0", + "version": "0.70.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-chat", - "version": "0.69.0", + "version": "0.70.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { @@ -143,7 +143,7 @@ "engines": { "node": ">=22.14.0", "npm": ">=9.0.0", - "vscode": "^1.141.0" + "vscode": "^1.142.0" } }, "node_modules/@anthropic-ai/sdk": { diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index aa834bda34a0..b4c41dac063d 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2,7 +2,7 @@ "name": "copilot-chat", "displayName": "GitHub Copilot", "description": "AI chat features powered by Copilot", - "version": "0.69.0", + "version": "0.70.0", "build": "1", "completionsCoreVersion": "1.378.1799", "internalLargeStorageAriaKey": "ec712b3202c5462fb6877acae7f1f9d7-c19ad55e-3e3c-4f99-984b-827f6d95bd9e-6917", @@ -22,7 +22,7 @@ "icon": "assets/copilot.png", "pricing": "Trial", "engines": { - "vscode": "^1.141.0", + "vscode": "^1.142.0", "npm": ">=9.0.0", "node": ">=22.14.0" }, diff --git a/package-lock.json b/package-lock.json index 916b20611d05..d3044e0bc7d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-oss-dev", - "version": "1.141.0", + "version": "1.142.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.141.0", + "version": "1.142.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 85c1f4842d39..cbabf9bd0f87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-oss-dev", - "version": "1.141.0", + "version": "1.142.0", "distro": "a0e7521b2a1e3289e4118fad155277b200aebd35", "author": { "name": "Microsoft Corporation" From 3468b27020de94813b2a0dad8cb347b30ef2f956 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Mon, 5 Oct 2026 10:40:06 +0000 Subject: [PATCH 47/49] fix: memory leak in debug adapter descriptor factory (#334103) fix: dispose debug adapter descriptor factories Co-authored-by: Dmitriy Vasyura --- .../api/browser/mainThreadDebugService.ts | 12 +--- .../browser/mainThreadDebugService.test.ts | 63 +++++++++++++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 src/vs/workbench/api/test/browser/mainThreadDebugService.test.ts diff --git a/src/vs/workbench/api/browser/mainThreadDebugService.ts b/src/vs/workbench/api/browser/mainThreadDebugService.ts index 53f4fc1185c7..6dd884e2434c 100644 --- a/src/vs/workbench/api/browser/mainThreadDebugService.ts +++ b/src/vs/workbench/api/browser/mainThreadDebugService.ts @@ -29,7 +29,7 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb private readonly _debugAdapters: Map; private _debugAdaptersHandleCounter = 1; private readonly _debugConfigurationProviders: Map; - private readonly _debugAdapterDescriptorFactories: Map; + private readonly _debugAdapterDescriptorFactories = this._toDispose.add(new DisposableMap()); private readonly _extHostKnownSessions: Set; private readonly _visualizerHandles = new Map(); private readonly _visualizerTreeHandles = new Map(); @@ -87,7 +87,6 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb this._debugAdapters = new Map(); this._debugConfigurationProviders = new Map(); - this._debugAdapterDescriptorFactories = new Map(); this._extHostKnownSessions = new Set(); const viewModel = this.debugService.getViewModel(); @@ -295,18 +294,13 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb return Promise.resolve(this._proxy.$provideDebugAdapter(handle, this.getSessionDto(session))); } }; - this._debugAdapterDescriptorFactories.set(handle, provider); - this._toDispose.add(this.debugService.getAdapterManager().registerDebugAdapterDescriptorFactory(provider)); + this._debugAdapterDescriptorFactories.set(handle, this.debugService.getAdapterManager().registerDebugAdapterDescriptorFactory(provider)); return Promise.resolve(undefined); } public $unregisterDebugAdapterDescriptorFactory(handle: number): void { - const provider = this._debugAdapterDescriptorFactories.get(handle); - if (provider) { - this._debugAdapterDescriptorFactories.delete(handle); - this.debugService.getAdapterManager().unregisterDebugAdapterDescriptorFactory(provider); - } + this._debugAdapterDescriptorFactories.deleteAndDispose(handle); } private getSession(sessionId: DebugSessionUUID | undefined): IDebugSession | undefined { diff --git a/src/vs/workbench/api/test/browser/mainThreadDebugService.test.ts b/src/vs/workbench/api/test/browser/mainThreadDebugService.test.ts new file mode 100644 index 000000000000..7228ac4d8472 --- /dev/null +++ b/src/vs/workbench/api/test/browser/mainThreadDebugService.test.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../base/common/event.js'; +import { toDisposable } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IAdapterManager, IDebugAdapterDescriptorFactory, IDebugModel, IDebugService, IViewModel } from '../../../contrib/debug/common/debug.js'; +import { IDebugVisualizerService } from '../../../contrib/debug/common/debugVisualizers.js'; +import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; +import { ExtensionHostKind } from '../../../services/extensions/common/extensionHostKind.js'; +import { mock } from '../../../test/common/workbenchTestServices.js'; +import { MainThreadDebugService } from '../../browser/mainThreadDebugService.js'; + +suite('MainThreadDebugService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('disposes debug adapter descriptor factory registrations when they are unregistered', async () => { + let registrationDisposed = false; + const adapterManager = new class extends mock() { + override registerDebugAdapterDescriptorFactory(_factory: IDebugAdapterDescriptorFactory) { + return toDisposable(() => registrationDisposed = true); + } + override unregisterDebugAdapterDescriptorFactory(_factory: IDebugAdapterDescriptorFactory): void { } + }; + const model = new class extends mock() { + override readonly onDidChangeBreakpoints = Event.None; + override getBreakpoints() { return []; } + override getFunctionBreakpoints() { return []; } + override getDataBreakpoints() { return []; } + }; + const viewModel = new class extends mock() { + override readonly onDidFocusSession = Event.None; + override readonly onDidFocusThread = Event.None; + override readonly onDidFocusStackFrame = Event.None; + }; + const debugService = new class extends mock() { + override readonly onDidNewSession = Event.None; + override readonly onWillNewSession = Event.None; + override readonly onDidEndSession = Event.None; + override getAdapterManager() { return adapterManager; } + override getModel() { return model; } + override getViewModel() { return viewModel; } + }; + const extHostContext = new class extends mock() { + override readonly remoteAuthority = ''; + override readonly extensionHostKind = ExtensionHostKind.LocalProcess; + override getProxy(): any { return {}; } + }; + const service = store.add(new MainThreadDebugService( + extHostContext, + debugService, + new class extends mock() { } + )); + + await service.$registerDebugAdapterDescriptorFactory('test', 1); + service.$unregisterDebugAdapterDescriptorFactory(1); + + assert.strictEqual(registrationDisposed, true); + }); +}); From 1d92a977c4568c38aeaed82510084c4bbbfb6d63 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Mon, 5 Oct 2026 10:40:32 +0000 Subject: [PATCH 48/49] fix: release terminal command history when clearing the normal buffer (#326931) * fix: clear terminal command history with buffer * fix: clear partial command detection markers * fix: clear scrollback command history * fix: preserve terminal command history when clearing alternate buffer --------- Co-authored-by: Dmitriy Vasyura --- .../common/capabilities/capabilities.ts | 2 + .../commandDetectionCapability.ts | 6 + .../partialCommandDetectionCapability.ts | 4 + .../terminal/browser/xterm/xtermTerminal.ts | 17 ++- .../commandDetectionCapability.test.ts | 17 ++- .../partialCommandDetectionCapability.test.ts | 18 ++- .../test/browser/xterm/xtermTerminal.test.ts | 121 +++++++++++++++++- 7 files changed, 175 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/terminal/common/capabilities/capabilities.ts b/src/vs/platform/terminal/common/capabilities/capabilities.ts index 2f10ef1fa118..c7a6f40da53a 100644 --- a/src/vs/platform/terminal/common/capabilities/capabilities.ts +++ b/src/vs/platform/terminal/common/capabilities/capabilities.ts @@ -239,6 +239,7 @@ export interface ICommandDetectionCapability { */ getCwdForLine(line: number): string | undefined; getCommandForLine(line: number): ITerminalCommand | ICurrentPartialCommand | undefined; + clearCommands(): void; handlePromptStart(options?: IHandleCommandOptions): void; handleContinuationStart(): void; handleContinuationEnd(): void; @@ -294,6 +295,7 @@ export interface IPartialCommandDetectionCapability { readonly type: TerminalCapability.PartialCommandDetection; readonly commands: readonly IMarker[]; readonly onCommandFinished: Event; + clearCommands(): void; } interface IBaseTerminalCommand { diff --git a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts index 5f54e9ef3681..490d129fa727 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts @@ -204,6 +204,12 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe } } + clearCommands(): void { + if (this._commands.length > 0) { + this._onCommandInvalidated.fire(this._commands.splice(0)); + } + } + setContinuationPrompt(value: string): void { this._promptInputModel.setContinuationPrompt(value); } diff --git a/src/vs/platform/terminal/common/capabilities/partialCommandDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/partialCommandDetectionCapability.ts index 5a631692fe70..7aed0021d624 100644 --- a/src/vs/platform/terminal/common/capabilities/partialCommandDetectionCapability.ts +++ b/src/vs/platform/terminal/common/capabilities/partialCommandDetectionCapability.ts @@ -78,4 +78,8 @@ export class PartialCommandDetectionCapability extends DisposableStore implement // Remove them this._commands.splice(this._commands.length - count, count); } + + clearCommands(): void { + this._commands.length = 0; + } } diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 800274db038d..2c0de0dec1d7 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -785,11 +785,20 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } clearBuffer(): void { + const isNormalBuffer = this.raw.buffer.active.type === 'normal'; + // Clearing the alternate buffer leaves the normal buffer's output and markers intact. + if (isNormalBuffer) { + this._decorationAddon.clearDecorations(); + this._capabilities.get(TerminalCapability.CommandDetection)?.clearCommands(); + this._capabilities.get(TerminalCapability.PartialCommandDetection)?.clearCommands(); + } this.raw.clear(); - // xterm.js does not clear the first prompt, so trigger these to simulate - // the prompt being written - this._capabilities.get(TerminalCapability.CommandDetection)?.handlePromptStart(); - this._capabilities.get(TerminalCapability.CommandDetection)?.handleCommandStart(); + if (isNormalBuffer) { + // xterm.js does not clear the first prompt, so trigger these to simulate + // the prompt being written + this._capabilities.get(TerminalCapability.CommandDetection)?.handlePromptStart(); + this._capabilities.get(TerminalCapability.CommandDetection)?.handleCommandStart(); + } this._accessibilitySignalService.playSignal(AccessibilitySignal.clear); } diff --git a/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts b/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts index ea011a736ade..bd383ff63d11 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts @@ -16,7 +16,7 @@ import { workbenchInstantiationService } from '../../../../../test/browser/workb type TestTerminalCommandMatch = Pick & { marker: { line: number } }; class TestCommandDetectionCapability extends CommandDetectionCapability { - clearCommands() { + clearCommandsForTest() { this._commands.length = 0; } } @@ -41,7 +41,7 @@ suite('CommandDetectionCapability', () => { deepStrictEqual(addEvents, capability.commands); // Clear the commands to avoid re-asserting past commands addEvents.length = 0; - capability.clearCommands(); + capability.clearCommandsForTest(); } async function printStandardCommand(prompt: string, command: string, output: string, cwd: string | undefined, exitCode: number) { @@ -93,6 +93,19 @@ suite('CommandDetectionCapability', () => { }]); }); + test('should invalidate all commands when cleared', async () => { + await printStandardCommand('$ ', 'echo foo', 'foo', undefined, 0); + await printStandardCommand('$ ', 'echo bar', 'bar', undefined, 0); + strictEqual(capability.commands.length, 2); + + const invalidatedCommands: ITerminalCommand[] = []; + store.add(capability.onCommandInvalidated(commands => invalidatedCommands.push(...commands))); + capability.clearCommands(); + + deepStrictEqual(capability.commands, []); + deepStrictEqual(invalidatedCommands.map(e => e.command), ['echo foo', 'echo bar']); + }); + test('should trim the command when command executed appears on the following line', async () => { await printStandardCommand('$ ', 'echo foo\r\n', 'foo', undefined, 0); await printCommandStart('$ '); diff --git a/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts b/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts index e506e1534958..3ffb8c546379 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { IMarker, Terminal } from '@xterm/xterm'; -import { deepEqual, deepStrictEqual } from 'assert'; +import { deepEqual, deepStrictEqual, strictEqual } from 'assert'; import { importAMDNodeModule } from '../../../../../../amdX.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { PartialCommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/partialCommandDetectionCapability.js'; @@ -65,4 +65,20 @@ suite('PartialCommandDetectionCapability', () => { onDidExecuteTextEmitter.fire(); deepEqual(addEvents.length, 2); }); + + test('should clear all commands including scrollback', async () => { + await writeP(xterm, 'ab'); + xterm.input('\x0d'); + await writeP(xterm, '\r\n\r\n'); + await writeP(xterm, 'cd'); + xterm.input('\x0d'); + await writeP(xterm, '\r\n'); + deepStrictEqual(capability.commands.map(e => e.line), [0, 2]); + await writeP(xterm, 'line\r\n'.repeat(xterm.rows)); + strictEqual(xterm.buffer.active.baseY > 0, true); + + capability.clearCommands(); + + deepStrictEqual(capability.commands, []); + }); }); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts index f8ead87632a2..56557b621a87 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Terminal } from '@xterm/xterm'; +import type { IDecoration, IDecorationOptions, Terminal } from '@xterm/xterm'; import { deepStrictEqual, ok, strictEqual } from 'assert'; import { importAMDNodeModule } from '../../../../../../amdX.js'; import { timeout } from '../../../../../../base/common/async.js'; @@ -17,6 +17,9 @@ import { IEditorOptions } from '../../../../../../editor/common/config/editorOpt import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IConfigurationChangeEvent } from '../../../../../../platform/configuration/common/configuration.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ITerminalCommand, TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; +import { CommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/commandDetectionCapability.js'; +import { PartialCommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/partialCommandDetectionCapability.js'; import { TerminalCapabilityStore } from '../../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; import { TestColorTheme, TestThemeService } from '../../../../../../platform/theme/test/common/testThemeService.js'; @@ -63,7 +66,11 @@ const defaultTerminalConfig: Partial = { scrollback: 10, fastScrollSensitivity: 2, mouseWheelScrollSensitivity: 1, - unicodeVersion: '6' + unicodeVersion: '6', + shellIntegration: { + enabled: true, + decorationsEnabled: 'both' + } }; function listenerCount(emitter: Emitter): number { @@ -78,6 +85,7 @@ suite('XtermTerminal', () => { let themeService: TestThemeService; let xterm: XtermTerminal; let XTermBaseCtor: typeof Terminal; + let capabilityStore: TerminalCapabilityStore; let onWillShutdown: Emitter; let lifecycleListenerCountBeforeXterm: number; @@ -109,7 +117,7 @@ suite('XtermTerminal', () => { XTermBaseCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; - const capabilityStore = store.add(new TerminalCapabilityStore()); + capabilityStore = store.add(new TerminalCapabilityStore()); xterm = store.add(instantiationService.createInstance(XtermTerminal, undefined, XTermBaseCtor, { cols: 80, rows: 30, @@ -129,6 +137,113 @@ suite('XtermTerminal', () => { strictEqual(xterm.raw.rows, 30); }); + for (const buffer of ['normal', 'alternate'] as const) { + test(`clearBuffer should ${buffer === 'normal' ? 'clear rich and partial command history including scrollback' : 'preserve normal-buffer command history and decorations when clearing the alternate buffer'}`, async () => { + class TestTerminal extends XTermBaseCtor { + override registerDecoration(options: IDecorationOptions): IDecoration | undefined { + const disposeListeners = new Set<() => unknown>(); + let isDisposed = false; + return { + marker: options.marker, + options, + get isDisposed() { return isDisposed; }, + dispose: () => { + isDisposed = true; + for (const listener of disposeListeners) { + listener(); + } + disposeListeners.clear(); + }, + onDispose: (listener: () => unknown) => { + disposeListeners.add(listener); + return { dispose: () => disposeListeners.delete(listener) }; + }, + onRender: (listener: (element: HTMLElement) => unknown) => { + listener(document.createElement('div')); + return { dispose() { } }; + } + } as unknown as IDecoration; + } + } + capabilityStore = store.add(new TerminalCapabilityStore()); + xterm = store.add(instantiationService.createInstance(XtermTerminal, undefined, TestTerminal, { + cols: 80, + rows: 30, + xtermColorProvider: { getBackgroundColor: () => undefined }, + capabilities: capabilityStore, + disableShellIntegrationReporting: true, + xtermAddonImporter: new TestXtermAddonImporter(), + }, undefined)); + const commandDetection = store.add(instantiationService.createInstance(CommandDetectionCapability, xterm.raw)); + const onDidExecuteText = store.add(new Emitter()); + const partialCommandDetection = store.add(new PartialCommandDetectionCapability(xterm.raw, onDidExecuteText.event)); + capabilityStore.add(TerminalCapability.CommandDetection, commandDetection); + capabilityStore.add(TerminalCapability.PartialCommandDetection, partialCommandDetection); + + xterm.raw.registerMarker(0); + commandDetection.handlePromptStart(); + await write('$ '); + commandDetection.handleCommandStart(); + await write('echo test'); + commandDetection.handleCommandExecuted(); + await write('\r\noutput\r\n'); + commandDetection.handleCommandFinished(0); + + await write('partial'); + xterm.raw.input('\r'); + await write('\r\n'); + await write('line\r\n'.repeat(xterm.raw.rows)); + commandDetection.handlePromptStart(); + await write('$ '); + commandDetection.handleCommandStart(); + + strictEqual(xterm.raw.buffer.active.baseY > 0, true); + strictEqual(commandDetection.commands.length, 1); + strictEqual(partialCommandDetection.commands.length, 1); + const decorations = (xterm.decorationAddon as unknown as { _decorations: Map })._decorations; + const clearedCommandMarkerId = commandDetection.commands[0].marker!.id; + strictEqual(decorations.has(clearedCommandMarkerId), true); + const invalidatedCommands: ITerminalCommand[] = []; + store.add(commandDetection.onCommandInvalidated(commands => invalidatedCommands.push(...commands))); + + const command = commandDetection.commands[0]; + const partialMarker = partialCommandDetection.commands[0]; + const decoration = decorations.get(clearedCommandMarkerId); + const normalBufferLines = Array.from({ length: xterm.raw.buffer.normal.length }, (_, i) => xterm.raw.buffer.normal.getLine(i)!.translateToString()); + const currentCommandStartMarker = commandDetection.currentCommand.commandStartMarker; + ok(currentCommandStartMarker); + if (buffer === 'alternate') { + await write('\x1b[?1049h'); + await write('alternate output\r\n'); + strictEqual(xterm.raw.buffer.active.type, 'alternate'); + } + + xterm.clearBuffer(); + + if (buffer === 'alternate') { + strictEqual(xterm.raw.buffer.active.cursorY, 0); + await write('\x1b[?1049l'); + strictEqual(xterm.raw.buffer.active.type, 'normal'); + deepStrictEqual(Array.from({ length: xterm.raw.buffer.normal.length }, (_, i) => xterm.raw.buffer.normal.getLine(i)!.translateToString()), normalBufferLines); + strictEqual(commandDetection.commands.length, 1); + strictEqual(commandDetection.commands[0], command); + strictEqual(partialCommandDetection.commands.length, 1); + strictEqual(partialCommandDetection.commands[0], partialMarker); + deepStrictEqual(invalidatedCommands, []); + strictEqual(command.marker!.isDisposed, false); + strictEqual(partialMarker.isDisposed, false); + strictEqual(decorations.get(clearedCommandMarkerId), decoration); + strictEqual(commandDetection.currentCommand.commandStartMarker, currentCommandStartMarker); + return; + } + + deepStrictEqual(commandDetection.commands, []); + deepStrictEqual(partialCommandDetection.commands, []); + deepStrictEqual(invalidatedCommands.map(e => e.command), ['echo test']); + strictEqual(decorations.has(clearedCommandMarkerId), false); + }); + } + suite('fontRendering', () => { async function setTerminalConfiguration(configuration: Partial): Promise { await configurationService.setUserConfiguration('terminal.integrated', { From dae0d5919e9b1f53260f547f20e55a99db97c7a4 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Mon, 5 Oct 2026 11:01:28 +0000 Subject: [PATCH 49/49] feature: reduce number of strings by 1200 (#327689) * perf: avoid premature listener stack traces * test: inject listener stack trace creation * refactor: keep listener stack change minimal --------- Co-authored-by: Dmitriy Vasyura --- src/vs/base/common/event.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index cf5fd39b7389..d455ebf06940 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -1293,7 +1293,7 @@ export class Emitter { let stack: Stacktrace | undefined; if (this._leakWarningThreshold !== undefined && this._size >= Math.ceil(this._leakWarningThreshold * 0.2)) { const leakageMon = this._getLeakageMonitor(); - if (leakageMon) { + if (leakageMon && this._size + 1 >= leakageMon.threshold) { // check and record this emitter for potential leakage contained.stack = Stacktrace.create(); removeMonitor = leakageMon.check(contained.stack, this._size + 1);