From 9b82522cd9f718076c5d73f7f7125caa053fb8ab Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 20 Aug 2026 15:54:16 -0700 Subject: [PATCH 01/15] inline chat: add experimental Agent Host backend (#331874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * inline chat: add experimental Agent Host backend Adds an experiment-gated (`chat.inlineChat.agentHost.enabled`, default off) Agent Host backend for editor inline chat, following the terminal chat migration. When disabled, inline chat behaves exactly as before. Because the Agent Host writes files directly to disk rather than streaming edits, review UI is hydrated from before/after snapshots instead: - `InlineChatSessionResolver` picks the Agent Host or the legacy local session, falling back on any failure and treating cancellation as cancellation rather than fallback. - `IChatEditReviewSession` is extracted as a narrow supertype of `IChatEditingSession` so a surface can supply reviewable entries without implementing checkpoints, storage, streaming edits or multi-diff. `editingSessionsObs` is typed to it, keeping editor-level review UI (decorations, hunk keep/undo, accessibility) working. - `InlineChatEditReviewSession` implements only that surface. It saves and snapshots the target, locks it read-only for the turn, and reuses `ChatEditingModifiedDocumentEntry` so diffing and hunk review come for free. Turns are bracketed with `startExternalEdit`/`stopExternalEdit` so disk-driven model reloads render in real time and stay cumulative across follow-up turns. - `IFilesConfigurationService.updateReadonly` accepts an `IMarkdownString` so a programmatic lock can explain itself instead of offering the generic "set writeable" affordance. Notebooks and untitled documents deliberately stay on the legacy path. Also fixes a pre-existing leak where every non-local session was written to the chat history index regardless of location, so throwaway inline (and terminal) sessions appeared in the session list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * inline chat: track off-target agent edits in real time The Agent Host emits an `externalEdit` progress part as each tool call completes, so files the agent touches outside the inline-chat target can be discovered during the turn rather than only at its end. `InlineChatEditReviewSession` now watches the response for those parts and creates a review entry as soon as one appears, seeding its baseline from the part's `beforeContentUri`. That baseline is the only trustworthy "before" for an off-target file: the agent writes to disk before announcing the edit, so reading current content would silently yield an empty diff. Entries enter external-edit mode so subsequent disk reloads keep their diffs live, matching the target file. `endTurn` keeps its sweep as an idempotent safety net. Deletes and renames are skipped — neither maps cleanly onto a single-URI `IModifiedFileEntry`. Fixes two attribution races that would drop agent edits from the diff: - A newly created entry was published through `entries` before external-edit mode was on, so an observer could see it and a disk reload could land in that window and be rebased into the baseline as a user edit. - An off-target entry carried over from an earlier turn only re-entered external-edit mode once its part arrived, but the disk write precedes the announcement. All tracked entries now enter external-edit mode at `beginTurn`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * inline chat: add experimental Agent Host backend Adds an Agent Host backend for editor inline chat. The setting `chat.inlineChat.agentHost.enabled` controls it and is off by default. When the setting is off, inline chat operates as before. The Agent Host writes files to disk. It does not stream edits into the editor. Inline chat therefore builds its review UI from before/after snapshots. - Adds `InlineChatSessionResolver`. It selects the Agent Host session or the legacy local session. It falls back to the legacy session on failure. It does not fall back when the user cancels. - Extracts `IChatEditReviewSession` as a supertype of `IChatEditingSession`. A surface can supply reviewable entries without checkpoints, storage, streaming edits, or multi-diff. Editor review UI, such as decorations and keep/undo, continues to operate. - Adds `InlineChatEditReviewSession`. It saves and snapshots the target file, makes the file read-only for the turn, and reuses `ChatEditingModifiedDocumentEntry`. Diff decorations and hunk review operate without new diff code. - Shows diff decorations in real time. Each turn starts and stops external-edit mode, so disk reloads count as agent edits. The diff stays cumulative across turns. - Tracks the files that the agent edits outside the target file. The Agent Host announces each edit when a tool call completes. The baseline content comes from that edit. - Lets `IFilesConfigurationService.updateReadonly` accept an `IMarkdownString`. A programmatic lock can then show its own reason. - Shows the current agent operation in the inline input placeholder. - Keeps notebooks and untitled documents on the legacy path. Makes throwaway (ephemeral) sessions start and run more quickly: - Disables MCP servers, subagents, and custom agents for these sessions. - Skips the turn-start checkpoint. This work is on the critical path of each turn. - Skips title generation and the rename instruction. The title is never shown. - Adds `enabledForEphemeralSessions` to server tool definitions. A tool must opt in before an ephemeral session receives it. Also keeps throwaway sessions out of the session lists. The host no longer sends `root/sessionAdded` for an ephemeral session. The chat history index no longer stores an external session from a transient surface. (Commit message generated by Copilot) * inline chat: address review feedback and fixture failures - Adds `getEditingSession` to the two component fixture mocks of `IChatEditingService`. The chat widget now calls this method, so the fixtures failed to render. - Cancels the turn when the pre-turn save is cancelled. The buffer stays dirty in that case, so the end-of-turn revert discarded the unsaved work of the user. - Records a created off-target file with `ChatEditKind.Created`. A rejection then deletes the file instead of leaving empty content on disk. - Cancels the request when turn preparation fails. Before this change the agent could write files while the file was not read-only and no review baseline existed. - Examines the session map again after the Agent Host resolves. Before this change two controllers for one file could each create a session. - Corrects the comment about custom agents for ephemeral sessions. The SDK can still find agents in the plugin directories. (Commit message generated by Copilot) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/agent.ts | 2 + .../agentHost/common/agentServerTools.ts | 16 +- .../common/meta/agentChatSurfaceMeta.ts | 82 +++- .../node/agentHostSessionTitleController.ts | 19 + .../agentHost/node/agentHostStateManager.ts | 7 +- .../platform/agentHost/node/agentService.ts | 1 + .../agentHost/node/agentSideEffects.ts | 18 +- .../node/claude/claudeAgentSession.ts | 9 +- .../node/claude/claudeServerToolMcpServer.ts | 5 +- .../agentHost/node/codex/codexAgent.ts | 2 +- .../agentHost/node/copilot/copilotAgent.ts | 5 +- .../node/copilot/copilotAgentSession.ts | 2 +- .../node/copilot/copilotSessionLauncher.ts | 28 +- .../node/copilot/copilotToolDisplay.ts | 10 +- .../node/shared/agentFeedbackServerTools.ts | 5 +- .../node/shared/agentMergeServerTools.ts | 4 +- .../node/shared/agentServerToolHost.ts | 18 +- .../node/shared/sessionServerTools.ts | 5 +- .../test/common/agentMetaReaders.test.ts | 53 +- .../agentHostSessionTitleController.test.ts | 26 +- .../test/node/agentHostStateManager.test.ts | 10 +- .../agentHost/test/node/agentService.test.ts | 4 +- .../test/node/agentSideEffects.test.ts | 82 +++- .../agentHost/test/node/claudeAgent.test.ts | 2 + .../node/claudeServerToolMcpServer.test.ts | 2 + .../test/node/codex/codexCreateChat.test.ts | 3 + .../test/node/copilotAgentSession.test.ts | 30 +- .../test/node/copilotSessionLauncher.test.ts | 7 + .../test/node/sessionServerTools.test.ts | 24 + .../browser/nullInlineChatSessionService.ts | 3 +- .../agentHost/agentHostChatContribution.ts | 2 +- .../chat/browser/chat.shared.contribution.ts | 9 + .../chatEditing/chatEditingEditorActions.ts | 20 +- .../chatEditingEditorContextKeys.ts | 4 +- .../chatEditing/chatEditingEditorOverlay.ts | 6 +- .../chatEditing/chatEditingServiceImpl.ts | 45 +- .../promptTimeline/promptTimelineModel.ts | 3 +- .../contrib/chat/browser/widget/chatWidget.ts | 4 +- .../common/chatService/chatServiceImpl.ts | 16 +- .../contrib/chat/common/constants.ts | 1 + .../chat/common/editing/chatEditingService.ts | 33 +- .../chatEditing/chatEditingService.test.ts | 77 ++- .../common/chatService/chatService.test.ts | 47 ++ .../browser/inlineChat.contribution.ts | 2 + .../browser/inlineChatController.ts | 165 ++++++- .../browser/inlineChatEditReviewSession.ts | 341 +++++++++++++ .../browser/inlineChatSessionResolver.ts | 91 ++++ .../browser/inlineChatSessionService.ts | 9 +- .../browser/inlineChatSessionServiceImpl.ts | 131 ++++- .../inlineChatEditReviewSession.test.ts | 454 ++++++++++++++++++ .../browser/inlineChatSessionResolver.test.ts | 318 ++++++++++++ .../browser/inlineChatSessionService.test.ts | 376 +++++++++++++++ .../common/filesConfigurationService.ts | 18 +- .../browser/filesConfigurationService.test.ts | 45 ++ .../editor/inlineChatZoneWidget.fixture.ts | 1 + ...aiCustomizationManagementEditor.fixture.ts | 1 + .../test/common/workbenchTestServices.ts | 3 +- 57 files changed, 2551 insertions(+), 155 deletions(-) create mode 100644 src/vs/workbench/contrib/inlineChat/browser/inlineChatEditReviewSession.ts create mode 100644 src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionResolver.ts create mode 100644 src/vs/workbench/contrib/inlineChat/test/browser/inlineChatEditReviewSession.test.ts create mode 100644 src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionResolver.test.ts create mode 100644 src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionService.test.ts diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index e1df75758448e1..4e66e6de8b2bfa 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -494,6 +494,8 @@ export function resolveAgentHostInstructions(context?: URI | IAgentChatContext): /** Fully resolved options for creating one chat. */ export interface IAgentCreateChatOptions { + /** Whether the owning session is transient and should skip durable-only provider work. */ + readonly isEphemeral?: boolean; /** Optional display title for the new chat. */ readonly title?: string; /** Optional model override; defaults to the session's model. */ diff --git a/src/vs/platform/agentHost/common/agentServerTools.ts b/src/vs/platform/agentHost/common/agentServerTools.ts index 7b99c8f28cc7dd..f9000f16670b63 100644 --- a/src/vs/platform/agentHost/common/agentServerTools.ts +++ b/src/vs/platform/agentHost/common/agentServerTools.ts @@ -5,6 +5,18 @@ import type { ToolDefinition, URI } from './state/sessionState.js'; +/** + * A server tool definition plus agent-host-local metadata that is not part of + * the wire protocol. + */ +export interface IAgentServerToolDefinition extends ToolDefinition { + /** + * Whether this tool is offered to ephemeral sessions. Defaults to `false` so + * throwaway surfaces do not pay for session-management tooling. + */ + readonly enabledForEphemeralSessions?: boolean; +} + /** * Server-side host for the agent host's **server tools** — tools that the * agent host owns and executes in-process (against a session's own state @@ -22,7 +34,9 @@ import type { ToolDefinition, URI } from './state/sessionState.js'; */ export interface IAgentServerToolHost { /** Every server tool definition across the contributed groups. */ - readonly definitions: readonly ToolDefinition[]; + readonly definitions: readonly IAgentServerToolDefinition[]; + /** Server tools eligible for the given session, honoring ephemeral eligibility. */ + getDefinitionsForSession(sessionUri: URI): readonly IAgentServerToolDefinition[]; /** Names of every server tool across the contributed groups. */ readonly toolNames: readonly string[]; /** Advertises all server tools on the session's `serverTools`. */ diff --git a/src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts b/src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts index 0bf13d1f6de5f4..d285172f9e3617 100644 --- a/src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/** VS Code-owned metadata describing the chat surface that created a session. */ +/** The metadata key for VS Code-owned chat-surface information. */ export const VSCODE_CHAT_SURFACE_META_KEY = 'vscode.chat.surface'; interface IHasChatSurfaceMeta { @@ -17,8 +17,17 @@ export interface ITerminalChatSurfaceMeta { readonly osName: string; } +/** Metadata describing an editor inline-chat surface. */ +export interface IEditorInlineChatSurfaceMeta { + readonly surface: 'editorInline'; + readonly languageId?: string; +} + +/** VS Code-owned metadata describing the chat surface that created a session. */ +export type IChatSurfaceMeta = ITerminalChatSurfaceMeta | IEditorInlineChatSurfaceMeta; + /** Reads recognized chat-surface metadata, dropping malformed values. */ -export function readChatSurfaceMeta(source: IHasChatSurfaceMeta): ITerminalChatSurfaceMeta | undefined { +export function readChatSurfaceMeta(source: IHasChatSurfaceMeta): IChatSurfaceMeta | undefined { // eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced chat-surface slot. const value = source._meta?.[VSCODE_CHAT_SURFACE_META_KEY]; if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -26,31 +35,52 @@ export function readChatSurfaceMeta(source: IHasChatSurfaceMeta): ITerminalChatS } const raw = value as Record; - if (raw['surface'] !== 'terminal' - || (raw['shellType'] !== undefined && typeof raw['shellType'] !== 'string') - || typeof raw['osName'] !== 'string') { - return undefined; - } + switch (raw['surface']) { + case 'terminal': + if ((raw['shellType'] !== undefined && typeof raw['shellType'] !== 'string') + || typeof raw['osName'] !== 'string') { + return undefined; + } - return { - surface: 'terminal', - ...(typeof raw['shellType'] === 'string' ? { shellType: raw['shellType'] } : {}), - osName: raw['osName'], - }; + return { + surface: 'terminal', + ...(typeof raw['shellType'] === 'string' ? { shellType: raw['shellType'] } : {}), + osName: raw['osName'], + }; + case 'editorInline': + if (raw['languageId'] !== undefined && typeof raw['languageId'] !== 'string') { + return undefined; + } + + return { + surface: 'editorInline', + ...(typeof raw['languageId'] === 'string' ? { languageId: raw['languageId'] } : {}), + }; + default: + return undefined; + } } /** Adds VS Code's typed chat-surface metadata to an open request metadata bag. */ -export function withChatSurfaceMeta(meta: Record | undefined, surface: ITerminalChatSurfaceMeta | undefined): Record | undefined { +export function withChatSurfaceMeta(meta: Record | undefined, surface: IChatSurfaceMeta | undefined): Record | undefined { if (!surface) { return meta; } - return { - ...(meta ?? {}), - [VSCODE_CHAT_SURFACE_META_KEY]: { + + const serializedSurface = surface.surface === 'terminal' + ? { surface: surface.surface, ...(surface.shellType !== undefined ? { shellType: surface.shellType } : {}), osName: surface.osName, - }, + } + : { + surface: 'editorInline', + ...(surface.languageId !== undefined ? { languageId: surface.languageId } : {}), + }; + + return { + ...(meta ?? {}), + [VSCODE_CHAT_SURFACE_META_KEY]: serializedSurface, }; } @@ -88,3 +118,21 @@ export function createTerminalChatInstruction(surface: ITerminalChatSurfaceMeta) '', ].join('\n'); } + +/** + * Builds the per-turn host instruction for an editor inline-chat surface. + */ +export function createEditorInlineChatInstruction(surface: IEditorInlineChatSurfaceMeta): string { + return [ + '', + 'You specialize in focused inline edits. Make the requested change directly.', + '- Edit only the file attached as the current editor context. Do not create, delete, or modify other files.', + '- Make the smallest edit that satisfies the request; preserve surrounding style and indentation.', + '- Focus on the user\'s selected range when one is provided.', + '- Avoid broad repository exploration or context-gathering unless required to resolve ambiguity.', + '- After making the edit, stop; do not run tests, builds, linters, or other verification, and never summarize the change.', + '- Produce the edit directly rather than explaining it or writing a tutorial.', + ...(surface.languageId !== undefined ? [`- The file's language is ${surface.languageId}.`] : []), + '', + ].join('\n'); +} diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index 20a4f585b448e2..e851c18fe2f394 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -116,6 +116,9 @@ export class AgentHostSessionTitleController extends Disposable { } seedTitleFromFirstMessage(channel: ProtocolURI, userPrompt: string, chatChannel?: ProtocolURI): void { + if (this._isEphemeralSession(channel)) { + return; + } const activeAgentTitleGenerationEnabled = this._isActiveAgentTitleGenerationEnabled(channel); const fallbackTitle = activeAgentTitleGenerationEnabled ? this._normalizeActiveAgentFallbackTitle(userPrompt) @@ -152,6 +155,9 @@ export class AgentHostSessionTitleController extends Disposable { /** Seeds and persists a provisional title suggested by a locally handled command. */ seedProvisionalTitle(channel: ProtocolURI, suggestedTitle: string, chatChannel?: ProtocolURI): void { + if (this._isEphemeralSession(channel)) { + return; + } const title = this._normalizeTitle(suggestedTitle, this._isActiveAgentTitleGenerationEnabled(channel) ? MAX_ACTIVE_AGENT_FALLBACK_TITLE_LENGTH : MAX_TITLE_LENGTH); if (!title) { return; @@ -285,6 +291,9 @@ export class AgentHostSessionTitleController extends Disposable { * always preserved. */ refineTitleFromFirstTurn(channel: ProtocolURI, chatChannel?: ProtocolURI): void { + if (this._isEphemeralSession(channel)) { + return; + } if (this._isActiveAgentTitleGenerationEnabled(channel)) { return; } @@ -365,6 +374,9 @@ export class AgentHostSessionTitleController extends Disposable { * so generation costs at most a single small-model call. */ generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void { + if (this._isEphemeralSession(channel)) { + return; + } if (this._isActiveAgentTitleGenerationEnabled(channel)) { this.markTitleAuto(channel, chatChannel, fallbackTitle); return; @@ -444,6 +456,9 @@ export class AgentHostSessionTitleController extends Disposable { } async prepareInstructionForAgent(channel: ProtocolURI, chatChannel: ProtocolURI): Promise { + if (this._isEphemeralSession(channel)) { + return undefined; + } if (!this._isActiveAgentTitleGenerationEnabled(channel)) { return undefined; } @@ -791,6 +806,10 @@ export class AgentHostSessionTitleController extends Disposable { : this._options.isActiveAgentTitleGenerationEnabled?.() === true; } + private _isEphemeralSession(channel: ProtocolURI): boolean { + return this._stateManager.isEphemeralSession(channel); + } + private async _readPersistedTitleSource(session: ProtocolURI, key: string): Promise { try { const ref = await this._options.sessionDataService.tryOpenDatabase?.(URI.parse(session)); diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 82303835f28c68..73c882cffcf6f2 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -24,7 +24,7 @@ import { arrayEquals, structuralEquals } from '../../../base/common/equals.js'; import { preserveProviderBackedRootConfigValues } from '../common/agentCustomizationSettings.js'; import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { readEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; -import { ITerminalChatSurfaceMeta, readChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; +import { type IChatSurfaceMeta, readChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; export interface IAgentHostStateManagerOptions { readonly changesetStateRetention?: IAgentHostChangesetStateRetentionOptions; @@ -331,6 +331,9 @@ export class AgentHostStateManager extends Disposable { } private _emitSessionAdded(summary: SessionSummary): void { + if (readEphemeralSessionMeta(summary).isEphemeral) { + return; + } this._summaryNotifier.announce(summary.resource, summary); this._publishedSessionSummaries.add(summary.resource); this._addedSessionSummaries.add(summary.resource); @@ -614,7 +617,7 @@ export class AgentHostStateManager extends Disposable { } /** Returns the typed VS Code surface metadata for a tracked session, when present. */ - getSessionSurfaceMeta(session: string): ITerminalChatSurfaceMeta | undefined { + getSessionSurfaceMeta(session: string): IChatSurfaceMeta | undefined { const entry = this._sessionStates.get(session); return entry ? readChatSurfaceMeta(entry.state) : undefined; } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 865413420411f5..b485175d433ec5 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -3323,6 +3323,7 @@ export class AgentService extends Disposable implements IAgentService { private _toCreateChatOptions(config: IAgentCreateSessionConfig): IAgentCreateChatOptions { return { + ...(config.session && this._stateManager.isEphemeralSession(config.session.toString()) ? { isEphemeral: true } : {}), ...(config.model ? { model: config.model } : {}), ...(config.agent ? { agent: config.agent } : {}), ...(config.workingDirectories ? { workingDirectories: config.workingDirectories } : {}), diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 2f9928c9d6e50a..b140e4ff382a25 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -23,7 +23,7 @@ import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js'; import { AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentToolPendingConfirmationSignal, type IAgentModelCallCompletedSignal } from '../common/agent.js'; -import { createTerminalChatInstruction } from '../common/meta/agentChatSurfaceMeta.js'; +import { createEditorInlineChatInstruction, createTerminalChatInstruction } from '../common/meta/agentChatSurfaceMeta.js'; import { readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; @@ -396,7 +396,8 @@ export class AgentSideEffects extends Disposable { this._cancelledTurnIds.set(envelope.channel, turnIds); } turnIds.add(envelope.action.turnId); - void this._checkpointService.discardTurnStartCheckpoint(URI.parse(parseRequiredSessionUriFromChatUri(envelope.channel)), URI.parse(envelope.channel), envelope.action.turnId).catch(() => undefined); + const sessionChannel = parseRequiredSessionUriFromChatUri(envelope.channel); + void this._checkpointService.discardTurnStartCheckpoint(URI.parse(sessionChannel), URI.parse(envelope.channel), envelope.action.turnId).catch(() => undefined); } this._syncSessionInputNeededForChatAction(envelope.channel, envelope.action); this._trackTurnUsage(envelope.channel, envelope.action); @@ -2213,7 +2214,12 @@ export class AgentSideEffects extends Disposable { this._turnTracker.setCurrentStage(turnChannel, turnId, failureStage); const resolvedAttachments = await this._resolveChatAttachments(message.attachments); const renameInstruction = await this._titleController.prepareInstructionForAgent(sessionChannel, chat); - const terminalSurface = this._stateManager.getSessionSurfaceMeta(sessionChannel); + const chatSurface = this._stateManager.getSessionSurfaceMeta(sessionChannel); + const chatSurfaceInstruction = chatSurface?.surface === 'terminal' + ? createTerminalChatInstruction(chatSurface) + : chatSurface?.surface === 'editorInline' + ? createEditorInlineChatInstruction(chatSurface) + : undefined; const hostInstructions = [ ...(this._agentConfigService.getRootValue(platformRootSchema, AgentHostMarkdownPlanRichLinksEnabledConfigKey) ? [createMarkdownPlanRichLinksInstruction(chat)] @@ -2221,12 +2227,14 @@ export class AgentSideEffects extends Disposable { ...(this._agentConfigService.getRootValue(platformRootSchema, AgentHostArtifactToolsConfigKey) ? [ARTIFACT_TOOLS_INSTRUCTION] : []), - ...(terminalSurface ? [createTerminalChatInstruction(terminalSurface)] : []), + ...(chatSurfaceInstruction ? [chatSurfaceInstruction] : []), ...(renameInstruction ? [renameInstruction] : []), ]; const sendContext = { ...clientOperationContext, ...(hostInstructions.length ? { hostInstructions } : {}) }; if (this._cancelledTurnIds.get(turnChannel)?.has(turnId)) { return; } - await this._checkpointService.captureTurnStartCheckpoint(URI.parse(sessionChannel), chatUri, turnId, resolvedWorkingDirectories); + if (!this._stateManager.isEphemeralSession(sessionChannel)) { + await this._checkpointService.captureTurnStartCheckpoint(URI.parse(sessionChannel), chatUri, turnId, resolvedWorkingDirectories); + } if (this._cancelledTurnIds.get(turnChannel)?.has(turnId)) { await this._checkpointService.discardTurnStartCheckpoint(URI.parse(sessionChannel), chatUri, turnId); return; diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index b9eaa1046568d9..87c826ce5c1c2f 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -832,8 +832,9 @@ export class ClaudeAgentSession extends Disposable { ): Promise<{ mcpServers: Record | undefined; deniedMcpServers: readonly ClaudeDeniedMcpServerSpec[]; allowedTools: readonly string[] | undefined }> { const externalServers = await this._buildExternalMcpServers(await this._getGitHubMcpServerConfiguration()); const clientServers = await buildClientMcpServers(this.toolDiff, this._pendingClientToolCalls, this._sdkService); - const serverToolServer = serverToolHost - ? await buildServerToolMcpServer(serverToolHost, this._chatChannelUri.toString(), this._sdkService) + const serverToolDefinitions = serverToolHost?.getDefinitionsForSession(resource.toString()); + const serverToolServer = serverToolHost && serverToolDefinitions?.length + ? await buildServerToolMcpServer(serverToolHost, this._chatChannelUri.toString(), this._sdkService, serverToolDefinitions) : undefined; const mcpServers = (Object.keys(externalServers.servers).length === 0 && !clientServers && !serverToolServer) ? undefined @@ -849,8 +850,8 @@ export class ClaudeAgentSession extends Disposable { // answer: the allow-list is baked into the SDK options here and would go // stale if a tool were allow-listed while it happened to have nothing to // confirm. - const autoApproveToolNames = serverToolHost - ? serverToolHost.toolNames.filter(name => !serverToolHost.canRequireConfirmation(name)) + const autoApproveToolNames = serverToolHost && serverToolDefinitions + ? serverToolDefinitions.filter(definition => !serverToolHost.canRequireConfirmation(definition.name)).map(definition => definition.name) : undefined; return { mcpServers, diff --git a/src/vs/platform/agentHost/node/claude/claudeServerToolMcpServer.ts b/src/vs/platform/agentHost/node/claude/claudeServerToolMcpServer.ts index 1f5794857df478..bb38c1e7c39687 100644 --- a/src/vs/platform/agentHost/node/claude/claudeServerToolMcpServer.ts +++ b/src/vs/platform/agentHost/node/claude/claudeServerToolMcpServer.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk'; -import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; +import type { IAgentServerToolDefinition, IAgentServerToolHost } from '../../common/agentServerTools.js'; import type { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; import { jsonSchemaToZodRawShape } from './clientTools/claudeJsonSchemaToZod.js'; @@ -58,8 +58,9 @@ export async function buildServerToolMcpServer( host: IAgentServerToolHost, chatUri: string, sdk: IClaudeAgentSdkService, + definitions: readonly IAgentServerToolDefinition[] = host.definitions, ): Promise { - const tools = await Promise.all(host.definitions.map(def => sdk.tool( + const tools = await Promise.all(definitions.map(def => sdk.tool( def.name, def.description ?? '', jsonSchemaToZodRawShape(def.inputSchema), diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 4f18e4361684ea..c1a19e253359c1 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -2235,7 +2235,7 @@ export class CodexAgent extends Disposable implements IAgent { * {@link _handleDynamicToolCallRpc} by name. */ private _buildDynamicTools(session: ICodexSession): DynamicToolSpec[] | undefined { - const serverTools = this._serverToolHost?.definitions ?? []; + const serverTools = this._serverToolHost?.getDefinitionsForSession(session.configurationResource.toString()) ?? []; const clientTools = session.clientToolSet.merged(); // Server tools first; a server tool name shadows a colliding client tool // (the agent host owns those names) and matches the routing order below. diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index ce52c65745eecb..4d3e85d315f281 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -254,6 +254,7 @@ interface IProvisionalSession { readonly sdkSessionId: string; readonly sessionUri: URI; readonly chat: URI; + readonly isEphemeral: boolean; /** * Folder the user picked at create time. Used as both the * pre-worktree working directory and the customization directory @@ -2965,6 +2966,7 @@ export class CopilotAgent extends Disposable implements IAgent { sdkSessionId, sessionUri: session, chat, + isEphemeral: options.isEphemeral === true, workingDirectory, workingDirectories: options.workingDirectories, model: options.model, @@ -3246,12 +3248,13 @@ export class CopilotAgent extends Disposable implements IAgent { let agentSession: CopilotAgentSession | undefined; let agent: AgentSelection | undefined; try { - const resolvedAgent = await this._resolveAgentWhenMaterializing(provisional, snapshot, workingDirectory); + const resolvedAgent = provisional.isEphemeral ? undefined : await this._resolveAgentWhenMaterializing(provisional, snapshot, workingDirectory); agent = resolvedAgent?.agent; const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client, sessionId: sdkSessionId, + isEphemeral: provisional.isEphemeral, workingDirectory, additionalDirectories: this._additionalCustomizationDirectories(resolvedWorkingDirectories), resolvedAgentName: resolvedAgent?.name, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 19fcb12d5f3730..5c8886f04155a0 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1782,7 +1782,7 @@ export class CopilotAgentSession extends Disposable { if (!host) { return []; } - return host.definitions.map(def => ({ + return host.definitions.filter(def => !this._launchPlan.isEphemeral || def.enabledForEphemeralSessions).map(def => ({ name: def.name, description: def.description ?? '', parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index cded739b32faa6..1a00daf1177500 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -33,6 +33,7 @@ import { toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServe import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { ShellManager, createShellTools, type IUnsandboxedCommandConfirmationRequest } from './copilotShellTools.js'; import { isGpt56Model } from './modelIdentifiers.js'; +import { EPHEMERAL_DISABLED_COPILOT_TOOLS } from './copilotToolDisplay.js'; import './prompts/allPrompts.js'; import { agentHostPromptRegistry, type IAgentHostPromptContext } from './prompts/promptRegistry.js'; import { describeSystemMessageConfig } from './prompts/systemMessage.js'; @@ -65,10 +66,11 @@ export const ContextTierConfigKey = 'contextTier'; const ReasoningEfforts = reasoningEffortLevels; type AgentHostReasoningEffort = ReasoningEffortLevel; -function disabledMcpServersSessionOption(plugins: readonly ICopilotPluginInfo[], disabledRootMcpServers: readonly string[] | undefined): Partial { +function disabledMcpServersSessionOption(plugins: readonly ICopilotPluginInfo[], disabledRootMcpServers: readonly string[] | undefined, additionalDisabledMcpServers: readonly string[] | undefined): Partial { const disabledMcpServers = [...new Set([ ...plugins.flatMap(plugin => plugin.disabledMcpServers ?? []), ...(disabledRootMcpServers ?? []), + ...(additionalDisabledMcpServers ?? []), ])]; return disabledMcpServers.length > 0 ? { disabledMcpServers } : {}; } @@ -207,6 +209,8 @@ type CopilotSessionClient = Pick !p.pluginDir || p.pluginDir.scheme !== Schemas.file); - const explicitMcpServers = plugins.flatMap(plugin => plugin.mcpServers.filter(server => + const explicitMcpServers = plan.isEphemeral ? [] : plugins.flatMap(plugin => plugin.mcpServers.filter(server => !plugin.disabledMcpServers?.includes(server.name) && isMcpServerExplicitlyProjected(plugin, server) )); - const customAgents = await toSdkSessionCustomAgents(plugins, plan.resolvedAgentName, this._fileService); + // An ephemeral session skips the explicit enumeration (and its file I/O). The SDK can + // still discover agents from `pluginDirectories`; suppressing that too would also drop + // skills and instructions, so it is left alone. + const customAgents = plan.isEphemeral ? [] : await toSdkSessionCustomAgents(plugins, plan.resolvedAgentName, this._fileService); const skillDirectories = toSdkSkillDirectories(pluginsWithoutDirs.flatMap(p => p.skills)); const instructionDirectories = toSdkInstructionDirectories(plugins.flatMap(p => p.instructions)); const model = plan.kind === 'create' ? plan.model : plan.fallback.model; @@ -773,7 +780,9 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { const availableTools = getToolFilterOverride(availableToolsOverride, 'availableTools', modelId, this._logService, plan.sessionId); const excludedTools = getToolFilterOverride(excludedToolsOverride, 'excludedTools', modelId, this._logService, plan.sessionId); const sdkAvailableTools = toSdkToolFilterPatterns(availableTools); - const sdkExcludedTools = toSdkToolFilterPatterns(excludedTools); + const sdkExcludedTools = plan.isEphemeral + ? [...(toSdkToolFilterPatterns(excludedTools) ?? []), ...EPHEMERAL_DISABLED_COPILOT_TOOLS] + : toSdkToolFilterPatterns(excludedTools); const modelCapabilitiesOverride = resolveModelCapabilityOverrideField(capabilityOverrides, model?.id, 'modelCapabilities', (value): value is Record => isObject(value), () => { this._logService.warn(`[Copilot:${plan.sessionId}] Ignoring invalid 'modelCapabilities' capability override for '${modelId}'; expected an object`); }); @@ -810,7 +819,14 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } return { ...byok, - ...disabledMcpServersSessionOption(plugins, plan.disabledRootMcpServers), + ...disabledMcpServersSessionOption( + plugins, + plan.disabledRootMcpServers, + plan.isEphemeral ? [ + ...plugins.flatMap(plugin => plugin.mcpServers.map(server => server.name)), + ...Object.keys(plan.snapshot.mcpServers), + ] : undefined, + ), clientName: AGENT_HOST_COPILOT_CLIENT_NAME, // Resume only: `_createSession` re-resolves the full effort for a create, // while a resumed session keeps the effort the runtime journaled unless @@ -831,7 +847,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { onPostToolUse: input => runtime.handlePostToolUse(input), onUserPromptSubmitted: () => runtime.handleUserPromptSubmitted(), }), - mcpServers: { ...toSdkMcpServersFromConfigMap(plan.snapshot.mcpServers), ...toSdkMcpServers(explicitMcpServers) }, + mcpServers: plan.isEphemeral ? {} : { ...toSdkMcpServersFromConfigMap(plan.snapshot.mcpServers), ...toSdkMcpServers(explicitMcpServers) }, onExitPlanModeRequest: (request, invocation) => runtime.handleExitPlanModeRequest(request, invocation), workingDirectory: plan.workingDirectory?.fsPath, customAgents, diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 463393179d6580..a0d6499b8d32dc 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -37,7 +37,7 @@ import { getServerToolDisplay } from '../shared/serverToolGroups.js'; * Known Copilot CLI tool names. These are the `toolName` values that appear * in `tool.execution_start` events from the SDK. */ -const enum CopilotToolName { +export const enum CopilotToolName { StrReplaceEditor = 'str_replace_editor', StrReplace = 'str_replace', Insert = 'insert', @@ -96,6 +96,14 @@ const enum CopilotToolName { CodeqlChecker = 'codeql_checker', } +/** + * Copilot CLI tools withheld from ephemeral sessions, where subagents only add + * latency. + */ +export const EPHEMERAL_DISABLED_COPILOT_TOOLS: readonly CopilotToolName[] = [ + CopilotToolName.Task, +]; + /** Parameters for the `bash` / `powershell` shell tools. */ interface ICopilotShellToolArgs { command: string; diff --git a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts index 1e36dd9a233bad..9a954efb246a85 100644 --- a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts @@ -7,6 +7,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { FEEDBACK_ANNOTATION_META_KEY, feedbackAnnotationEntryMeta, readFeedbackAnnotationMeta, resolveFeedbackEntryAuthor, VIEW_UNREVIEWED_COMMENTS_TOOL_NAME, ADD_COMMENT_TOOL_NAME, type IFeedbackAnnotationMeta } from '../../common/meta/agentFeedbackAnnotations.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; +import type { IAgentServerToolDefinition } from '../../common/agentServerTools.js'; import type { AnnotationsAction } from '../../common/state/sessionActions.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { parseChatUri, type Annotation, type AnnotationsState, type StringOrMarkdown, type TextRange, type ToolDefinition } from '../../common/state/sessionState.js'; @@ -114,11 +115,11 @@ const resolveCommentsInputSchema: ToolDefinition['inputSchema'] = { }; /** - * Protocol {@link ToolDefinition}s for the feedback server tools, advertised on + * {@link IAgentServerToolDefinition}s for the feedback server tools, advertised on * {@link SessionState.serverTools} so clients know these tools are owned and * executed by the agent host. */ -export const feedbackServerToolDefinitions: ToolDefinition[] = [ +export const feedbackServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: addCommentToolName, title: 'Add Comment (Agent Feedback)', diff --git a/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts b/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts index f8b907443d5ebd..8c47629fbb8604 100644 --- a/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../nls.js'; -import type { ToolDefinition } from '../../common/state/sessionState.js'; +import type { IAgentServerToolDefinition } from '../../common/agentServerTools.js'; import type { AgentHostStateManager } from '../agentHostStateManager.js'; import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } from './agentServerToolHost.js'; @@ -12,7 +12,7 @@ export const readAgentMergeCIToolName = 'readAgentMergeCI'; export const replyToAgentMergeReviewThreadToolName = 'replyToAgentMergeReviewThread'; export const rerunAgentMergeWorkflowToolName = 'rerunAgentMergeWorkflow'; -const definitions: readonly ToolDefinition[] = [ +const definitions: readonly IAgentServerToolDefinition[] = [ { name: readAgentMergeCIToolName, title: 'Read Agent Merge CI', diff --git a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts index 73afe258872722..23a5956e9e3e21 100644 --- a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts +++ b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; +import type { IAgentServerToolDefinition, IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { parseRequiredSessionUriFromChatUri, type StringOrMarkdown, type ToolDefinition, type URI } from '../../common/state/sessionState.js'; import type { AgentHostStateManager } from '../agentHostStateManager.js'; @@ -57,7 +57,7 @@ export interface IServerToolExecutionContext { */ export interface IServerToolGroup { /** Tool definitions this group advertises on the session's `serverTools`. */ - readonly definitions: readonly ToolDefinition[]; + readonly definitions: readonly IAgentServerToolDefinition[]; /** Whether a contributed tool is currently enabled for advertisement and execution. */ isEnabled(toolName: string): boolean; /** @@ -132,10 +132,16 @@ export class AgentServerToolHost implements IAgentServerToolHost { } } - get definitions(): readonly ToolDefinition[] { + get definitions(): readonly IAgentServerToolDefinition[] { return this._groups.flatMap(group => group.definitions.filter(definition => group.isEnabled(definition.name))); } + getDefinitionsForSession(sessionUri: URI): readonly IAgentServerToolDefinition[] { + return this._stateManager.isEphemeralSession(sessionUri) + ? this.definitions.filter(definition => definition.enabledForEphemeralSessions) + : this.definitions; + } + get toolNames(): readonly string[] { return this.definitions.map(definition => definition.name); } @@ -147,7 +153,7 @@ export class AgentServerToolHost implements IAgentServerToolHost { } this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionServerToolsChanged, - tools: [...this.definitions], + tools: this._toProtocolDefinitions(this.getDefinitionsForSession(sessionUri)), }); } @@ -184,6 +190,10 @@ export class AgentServerToolHost implements IAgentServerToolHost { }; } + private _toProtocolDefinitions(definitions: readonly IAgentServerToolDefinition[]): ToolDefinition[] { + return definitions.map(({ enabledForEphemeralSessions: _enabledForEphemeralSessions, ...definition }) => definition); + } + private _isEnabledForSession(group: IServerToolGroup, chatUri: URI, toolName: string): boolean { const advertisedTools = this._stateManager.getSessionState(chatUri)?.serverTools; return advertisedTools diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index a435fc24175a97..045fd09c7378e5 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -9,6 +9,7 @@ import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; import { AgentSession, type AgentProvider, type IAgentCreateSessionConfig, type IAgentModelInfo, type IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; +import type { IAgentServerToolDefinition } from '../../common/agentServerTools.js'; import { buildChatUri, buildDefaultChatUri, getInlineToolInput, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionGitState, readSessionGitHubState, readSessionOrchestration, ResponsePartKind, ToolCallStatus, TurnState, type ISessionOrchestration, type Message, type ModelSelection, type ResponsePart, type SessionIdleNotification, type ToolCallState, type ToolDefinition, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; import { buildOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; @@ -135,8 +136,8 @@ const getSessionContextInputSchema: ToolDefinition['inputSchema'] = { required: ['session'], }; -/** Protocol tool definitions for the session-management server tools. */ -export const sessionServerToolDefinitions: ToolDefinition[] = [ +/** Server tool definitions for session management. */ +export const sessionServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: SessionServerToolName.ListSessions, title: 'List Sessions', diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index d34c59e325c074..a71b54093020e8 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { readToolCallMeta, toToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; -import { readChatSurfaceMeta, withChatSurfaceMeta, createTerminalChatInstruction } from '../../common/meta/agentChatSurfaceMeta.js'; +import { createEditorInlineChatInstruction, createTerminalChatInstruction, readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { readAgentCustomizationMeta, toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; import { getCommandArgumentHint, getCompletionAction, readCompletionAttachmentMeta, toCommandCompletionAttachmentMeta, toSkillCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; import { CustomizationType, MessageAttachmentKind, ToolCallStatus, hasReportedUsage, readUsageInfoMeta, type AgentCustomization, type ClientPluginCustomization, type ToolCallState, type UsageInfo } from '../../common/state/sessionState.js'; @@ -123,6 +123,22 @@ suite('Agent host _meta readers', () => { { existing: 'value', 'vscode.chat.surface': { surface: 'terminal', shellType: 'bash', osName: 'Linux' } }, ); }); + + test('reads and writes editor inline metadata', () => { + assert.deepStrictEqual( + readChatSurfaceMeta({ _meta: withChatSurfaceMeta(undefined, { surface: 'editorInline', languageId: 'typescript' }) }), + { surface: 'editorInline', languageId: 'typescript' }, + ); + assert.deepStrictEqual( + readChatSurfaceMeta({ _meta: withChatSurfaceMeta(undefined, { surface: 'editorInline' }) }), + { surface: 'editorInline' }, + ); + assert.strictEqual(readChatSurfaceMeta({ _meta: { 'vscode.chat.surface': { surface: 'editorInline', languageId: 1 } } }), undefined); + assert.deepStrictEqual( + withChatSurfaceMeta({ existing: 'value' }, { surface: 'editorInline', languageId: 'typescript' }), + { existing: 'value', 'vscode.chat.surface': { surface: 'editorInline', languageId: 'typescript' } }, + ); + }); }); suite('createTerminalChatInstruction', () => { @@ -154,6 +170,41 @@ suite('Agent host _meta readers', () => { }); }); + suite('createEditorInlineChatInstruction', () => { + test('targets the attached editor file and includes its language when known', () => { + const typescript = createEditorInlineChatInstruction({ surface: 'editorInline', languageId: 'typescript' }); + const languageUnknown = createEditorInlineChatInstruction({ surface: 'editorInline' }); + assert.deepStrictEqual({ + typescript, + languageUnknown, + }, { + typescript: [ + '', + 'You specialize in focused inline edits. Make the requested change directly.', + '- Edit only the file attached as the current editor context. Do not create, delete, or modify other files.', + '- Make the smallest edit that satisfies the request; preserve surrounding style and indentation.', + '- Focus on the user\'s selected range when one is provided.', + '- Avoid broad repository exploration or context-gathering unless required to resolve ambiguity.', + '- After making the edit, stop; do not run tests, builds, linters, or other verification, and never summarize the change.', + '- Produce the edit directly rather than explaining it or writing a tutorial.', + '- The file\'s language is typescript.', + '', + ].join('\n'), + languageUnknown: [ + '', + 'You specialize in focused inline edits. Make the requested change directly.', + '- Edit only the file attached as the current editor context. Do not create, delete, or modify other files.', + '- Make the smallest edit that satisfies the request; preserve surrounding style and indentation.', + '- Focus on the user\'s selected range when one is provided.', + '- Avoid broad repository exploration or context-gathering unless required to resolve ambiguity.', + '- After making the edit, stop; do not run tests, builds, linters, or other verification, and never summarize the change.', + '- Produce the edit directly rather than explaining it or writing a tutorial.', + '', + ].join('\n'), + }); + }); + }); + suite('readAgentCustomizationMeta', () => { test('reads userInvocable, ignores garbage, round-trips', () => { assert.deepStrictEqual(readAgentCustomizationMeta(agentCustomization(undefined)), {}); diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index 2cf7198df52ad3..391f1123135207 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -12,6 +12,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; +import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { buildChatUri, buildDefaultChatUri, MessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ResponsePart, type SessionSummary, type ToolCallCompletedState, type Turn } from '../../common/state/sessionState.js'; import { type AutoMergeMethod, type CreatedPullRequest, type GitHubIssueOrPullRequest, type IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; @@ -102,7 +103,7 @@ suite('AgentHostSessionTitleController', () => { teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); - function createSummary(session: URI, title = ''): SessionSummary { + function createSummary(session: URI, title = '', isEphemeral = false): SessionSummary { return { resource: session.toString(), provider: 'copilot', @@ -110,6 +111,7 @@ suite('AgentHostSessionTitleController', () => { status: SessionStatus.Idle, createdAt: new Date(1).toISOString(), modifiedAt: new Date(1).toISOString(), + ...(isEphemeral ? { _meta: withEphemeralSessionMeta(undefined, true) } : {}), }; } @@ -132,6 +134,7 @@ suite('AgentHostSessionTitleController', () => { gitHubContextRequestTimeout?: number, getGitHubHost = () => 'github.com', activeAgentTitleGeneration = false, + isEphemeral = false, ): { controller: AgentHostSessionTitleController; stateManager: AgentHostStateManager; @@ -144,7 +147,7 @@ suite('AgentHostSessionTitleController', () => { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const db = new TestSessionDatabase(); const session = URI.parse('agenthost-session://copilot/session-title-test'); - stateManager.createSession(createSummary(session, title)); + stateManager.createSession(createSummary(session, title, isEphemeral)); const titleActions: string[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => { if (e.action.type === ActionType.SessionTitleChanged) { @@ -209,6 +212,25 @@ suite('AgentHostSessionTitleController', () => { assert.strictEqual(await controller.prepareInstructionForAgent(session.toString(), buildDefaultChatUri(session)), undefined); }); + test('does not generate or instruct titles for ephemeral sessions', async () => { + const { controller, session, titleActions, copilotApiService } = setup(undefined, '', undefined, undefined, undefined, undefined, undefined, true, true); + + controller.seedTitleFromFirstMessage(session.toString(), 'Optimize an inline edit'); + controller.seedProvisionalTitle(session.toString(), 'Provisional inline edit'); + controller.refineTitleFromFirstTurn(session.toString()); + controller.generateForkedTitle(session.toString(), undefined, [], 'Forked inline edit'); + + assert.deepStrictEqual({ + titleActions, + utilityCalls: copilotApiService.utilityCalls.length, + instruction: await controller.prepareInstructionForAgent(session.toString(), buildDefaultChatUri(session)), + }, { + titleActions: [], + utilityCalls: 0, + instruction: undefined, + }); + }); + test('materialized server tools override later root setting changes', async () => { const enabled = setup(undefined, '', undefined, undefined, undefined, undefined, undefined, false); enabled.stateManager.dispatchServerAction(enabled.session.toString(), { diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 5cf4090d768f90..25608e1d0cbabf 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -16,6 +16,7 @@ import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; import { withAgentCustomizationSettings } from '../../common/agentCustomizationSettings.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; +import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; suite('AgentHostStateManager', () => { @@ -293,14 +294,17 @@ suite('AgentHostStateManager', () => { }); }); - test('createSession emits sessionAdded notification', () => { + test('createSession emits sessionAdded only for non-ephemeral sessions', () => { const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); manager.createSession(makeSessionSummary()); + manager.createSession({ + ...makeSessionSummary(URI.from({ scheme: 'copilot', path: '/ephemeral-session' }).toString()), + _meta: withEphemeralSessionMeta(undefined, true), + }); - assert.strictEqual(notifications.length, 1); - assert.strictEqual(notifications[0].type, NotificationType.SessionAdded); + assert.deepStrictEqual(notifications.map(notification => notification.type), [NotificationType.SessionAdded]); }); test('default chat inherits the session working directory resolved at materialization', () => { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index c5d1e6d771a6ed..ada5a080d5730c 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -8390,7 +8390,7 @@ suite('AgentService (node dispatcher)', () => { provider: 'copilot', workingDirectories: [URI.file('/repo')], _meta: { - ...withChatSurfaceMeta(withEphemeralSessionMeta(undefined, true), { surface: 'terminal', shellType: 'pwsh', osName: 'Windows' }), + ...withChatSurfaceMeta(withEphemeralSessionMeta(undefined, true), { surface: 'editorInline', languageId: 'typescript' }), // Session `_meta` is a whitelist, so an unrecognized slot must not survive. 'vscode.chat.unknownFutureSlot': { hello: 'world' }, }, @@ -8403,7 +8403,7 @@ suite('AgentService (node dispatcher)', () => { unknownSlot: state?._meta?.['vscode.chat.unknownFutureSlot'], }, { ephemeral: true, - surface: { surface: 'terminal', shellType: 'pwsh', osName: 'Windows' }, + surface: { surface: 'editorInline', languageId: 'typescript' }, unknownSlot: undefined, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index c90696623859ee..cb65545d70cb7b 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -22,6 +22,7 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession, AgentSignal, IAgent, resolveSubagentChatParent, SubagentChatSignal, type IAgentChatContext } from '../../common/agent.js'; import { buildDefaultChangesetCatalog } from '../../common/changesetUri.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; +import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; @@ -718,6 +719,32 @@ suite('AgentSideEffects', () => { ].join('\n')]); }); + test('adds focused edit guidance for an editor inline-chat surface', async () => { + setupSession(undefined, withChatSurfaceMeta(undefined, { surface: 'editorInline', languageId: 'typescript' })); + + sideEffects.handleAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'Rename the function', origin: { kind: MessageKind.User } }, + }); + await waitForSendMessageCalls(1); + + const sendContext = agent.chatContexts.find(call => call.boundary === 'sendMessage')?.context; + assert.deepStrictEqual(!URI.isUri(sendContext) ? sendContext?.hostInstructions : undefined, [[ + '', + 'You specialize in focused inline edits. Make the requested change directly.', + '- Edit only the file attached as the current editor context. Do not create, delete, or modify other files.', + '- Make the smallest edit that satisfies the request; preserve surrounding style and indentation.', + '- Focus on the user\'s selected range when one is provided.', + '- Avoid broad repository exploration or context-gathering unless required to resolve ambiguity.', + '- After making the edit, stop; do not run tests, builds, linters, or other verification, and never summarize the change.', + '- Produce the edit directly rather than explaining it or writing a tutorial.', + '- The file\'s language is typescript.', + '', + ].join('\n')]); + }); + test('passes the dispatching client id and type to sendMessage', async () => { setupSession(); const action: ChatAction = { @@ -1197,7 +1224,7 @@ suite('AgentSideEffects', () => { * how the agent host creates a session whose worktree/SDK setup happens on * the first `sendMessage`. */ - function setupProvisionalSession(): void { + function setupProvisionalSession(isEphemeral = false): void { stateManager.createSession({ resource: sessionUri.toString(), provider: 'mock', @@ -1205,6 +1232,7 @@ suite('AgentSideEffects', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), + ...(isEphemeral ? { _meta: withEphemeralSessionMeta(undefined, true) } : {}), }, { emitNotification: false }); } @@ -1328,6 +1356,34 @@ suite('AgentSideEffects', () => { }]); }); + test('skips turn-start checkpoint capture for an ephemeral session', async () => { + setupProvisionalSession(true); + let captureCount = 0; + const checkpoints: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + captureTurnStartCheckpoint: async () => { captureCount++; }, + }; + const localSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + resolveWorkingDirectoryBeforeSend: async () => [URI.file('/wd')], + onTurnComplete: () => { }, + }, undefined, NullTelemetryService, undefined, undefined, checkpoints); + const turnStarted = { + type: ActionType.ChatTurnStarted, + startedAt: '2025-01-01T00:00:00.000Z', + turnId: 'turn-1', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + } as const; + stateManager.dispatchClientAction(defaultChatUri, turnStarted, { clientId: 'test', clientSeq: 1 }); + + localSideEffects.handleAction(defaultChatUri, turnStarted); + await waitForSendMessageCalls(1); + + assert.strictEqual(captureCount, 0); + }); + test('client cancellation discards the pending turn start', async () => { setupProvisionalSession(); const discarded = new DeferredPromise(); @@ -1357,6 +1413,30 @@ suite('AgentSideEffects', () => { await discarded.p; }); + test('discards a turn-start checkpoint for an ephemeral session', async () => { + setupProvisionalSession(true); + let discardCount = 0; + const checkpoints: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + discardTurnStartCheckpoint: async () => { discardCount++; }, + }; + createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + onTurnComplete: () => { }, + }, undefined, NullTelemetryService, undefined, undefined, checkpoints); + + stateManager.dispatchClientAction(defaultChatUri, { + type: ActionType.ChatTurnCancelled, + turnId: 'turn-1', + duration: 0, + }, { clientId: 'test', clientSeq: 1 }); + await new Promise(resolve => setTimeout(resolve)); + + assert.strictEqual(discardCount, 1); + }); + test('cancellation before send skips turn-start capture', async () => { setupProvisionalSession(); let captureCount = 0; diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index f9e27fd7b8fbcb..86157080402e1b 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -6896,6 +6896,7 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { confirmationRequiredForSession = false; advertise(): void { } + getDefinitionsForSession(): readonly ToolDefinition[] { return this.definitions; } canRequireConfirmation(): boolean { return true; } requiresConfirmation(): boolean { return this.confirmationRequiredForSession; } executeTool(): string { return 'ok'; } @@ -10924,6 +10925,7 @@ suite('ClaudeAgent — host seams', () => { definitions: [{ name: toolName, inputSchema: { type: 'object', properties: {} } }], toolNames: [toolName], advertise: () => { }, + getDefinitionsForSession: () => [{ name: toolName, inputSchema: { type: 'object', properties: {} } }], canRequireConfirmation: () => false, requiresConfirmation: () => false, executeTool: chatUri => { diff --git a/src/vs/platform/agentHost/test/node/claudeServerToolMcpServer.test.ts b/src/vs/platform/agentHost/test/node/claudeServerToolMcpServer.test.ts index a7a3b6800e81fe..4a433d43f131da 100644 --- a/src/vs/platform/agentHost/test/node/claudeServerToolMcpServer.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeServerToolMcpServer.test.ts @@ -50,6 +50,8 @@ class FakeServerToolHost implements IAgentServerToolHost { advertise(): void { } + getDefinitionsForSession(): readonly ToolDefinition[] { return this.definitions; } + canRequireConfirmation(_toolName: string): boolean { return false; } requiresConfirmation(_sessionUri: string, _toolName: string): boolean { return false; } diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index 4342e2cbaf67c1..7da415dd4db895 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -248,6 +248,7 @@ function createRecordingServerToolHost(advertised: string[]): IAgentServerToolHo definitions: [], toolNames: [], advertise: session => advertised.push(session.toString()), + getDefinitionsForSession: () => [], canRequireConfirmation: () => false, requiresConfirmation: () => false, executeTool: () => '', @@ -260,6 +261,7 @@ function createThrowingAdvertiseServerToolHost(message: string): IAgentServerToo definitions: [], toolNames: [], advertise: () => { throw new Error(message); }, + getDefinitionsForSession: () => [], canRequireConfirmation: () => false, requiresConfirmation: () => false, executeTool: () => '', @@ -277,6 +279,7 @@ function createRecordingChatServerToolHost(calls: { readonly method: 'requiresCo definitions: [{ name: PEER_TEST_TOOL_NAME, description: 'test', inputSchema: { type: 'object' } }], toolNames: [PEER_TEST_TOOL_NAME], advertise: () => { }, + getDefinitionsForSession: () => [{ name: PEER_TEST_TOOL_NAME, description: 'test', inputSchema: { type: 'object' } }], canRequireConfirmation: () => false, requiresConfirmation: (chatUri, toolName) => { calls.push({ method: 'requiresConfirmation', chatUri: chatUri.toString() }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 9c3945f0d7db13..d1fc372337af5f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -36,7 +36,7 @@ import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService, type ISessionDatabase } from '../../common/sessionDataService.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction, type StateAction } from '../../common/state/sessionActions.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, createSessionState, getInlineToolInput, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, withSessionPromptCacheState, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type ToolResultTerminalContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, createSessionState, getInlineToolInput, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, withSessionPromptCacheState, type ToolResultContent, type ToolResultFileEditContent, type ToolResultTerminalContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; import { TerminalClaimKind } from '../../common/state/protocol/state.js'; import { toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { STREAMING_TOOL_DISPLAY_INTERVAL_MS } from '../../common/streamingToolCallDisplay.js'; @@ -63,7 +63,8 @@ import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../common/san import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; import { createNoopGitService, createSessionDataService, createZeroDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { OtelData } from '../../common/otlp/otlpLogEmitter.js'; -import { IAgentServerToolHost } from '../../common/agentServerTools.js'; +import { type IAgentServerToolDefinition, IAgentServerToolHost } from '../../common/agentServerTools.js'; +import { SessionServerToolName } from '../../common/serverToolNames.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest, type IRestrictedTelemetryContext } from '../../node/shared/copilotApiService.js'; import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; @@ -671,6 +672,8 @@ async function createAgentSession(disposables: DisposableStore, options?: { chatChannelUri?: URI; /** Optional server-tool host wired into the session. */ serverToolHost?: IAgentServerToolHost; + /** Whether the launch plan represents an ephemeral session. */ + isEphemeral?: boolean; /** Platform used to compute the SDK sandbox policy. Defaults to `'linux'` so sandbox tests are deterministic. */ platform?: NodeJS.Platform; githubToken?: string; @@ -743,6 +746,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { snapshot: options?.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }, shellManager: undefined, githubToken: options?.githubToken, + isEphemeral: options?.isEphemeral, }; const model = options?.modelId ? { id: options.modelId } : undefined; const launchPlan: CopilotSessionLaunchPlan = options?.resume @@ -8921,14 +8925,13 @@ suite('CopilotAgentSession', () => { suite('server tools', () => { - const fakeToolDefinitions: readonly ToolDefinition[] = [ + const fakeToolDefinitions: readonly IAgentServerToolDefinition[] = [ { name: 'serverToolA', description: 'A', inputSchema: { type: 'object', properties: {} } }, { name: 'serverToolB', description: 'B', inputSchema: { type: 'object', properties: {} } }, ]; class FakeServerToolHost implements IAgentServerToolHost { - readonly definitions: readonly ToolDefinition[] = fakeToolDefinitions; - readonly toolNames: readonly string[] = fakeToolDefinitions.map(def => def.name); + readonly toolNames: readonly string[]; readonly advertised: string[] = []; readonly executions: Array<{ sessionUri: string; toolName: string; rawArgs: unknown }> = []; readonly confirmationToolNames = new Set(); @@ -8936,10 +8939,16 @@ suite('CopilotAgentSession', () => { result = 'ok'; error: Error | undefined; + constructor(readonly definitions: readonly IAgentServerToolDefinition[] = fakeToolDefinitions) { + this.toolNames = definitions.map(def => def.name); + } + advertise(sessionUri: string): void { this.advertised.push(sessionUri); } + getDefinitionsForSession(): readonly IAgentServerToolDefinition[] { return this.definitions; } + canRequireConfirmation(toolName: string): boolean { return this.confirmationToolNames.has(toolName); } requiresConfirmation(_sessionUri: string, toolName: string): boolean { return this.sessionConfirmationToolNames.has(toolName); } @@ -8968,6 +8977,17 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(tools.map(t => t.defer), tools.map(() => 'never')); }); + test('exposes only ephemeral-enabled server tools in an ephemeral session', async () => { + const serverToolHost = new FakeServerToolHost([ + ...fakeToolDefinitions, + { name: 'ephemeralServerTool', description: 'Available in ephemeral sessions', inputSchema: { type: 'object', properties: {} }, enabledForEphemeralSessions: true }, + { name: SessionServerToolName.RenameChat, description: 'Rename the chat', inputSchema: { type: 'object', properties: {} } }, + ]); + const { runtime } = await createAgentSession(disposables, { serverToolHost, isEphemeral: true }); + + assert.deepStrictEqual(runtime.createServerSdkTools().map(tool => tool.name), ['ephemeralServerTool']); + }); + test('server tool handler routes to the host and returns a success result', async () => { const serverToolHost = new FakeServerToolHost(); serverToolHost.result = 'listed 2 comments'; diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index e6305ac745cd38..d0e9fb7f7d730c 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -453,6 +453,7 @@ suite('CopilotSessionLauncher shared session config', () => { try { sessions.add(await launcher.launch(createPlan, testRuntime)); sessions.add(await launcher.launch(resumePlan, testRuntime)); + sessions.add(await launcher.launch({ ...createPlan, isEphemeral: true }, testRuntime)); assert.deepStrictEqual({ createClientName: createConfigs[0].clientName, @@ -475,6 +476,9 @@ suite('CopilotSessionLauncher shared session config', () => { resumeHasExitPlanHandler: typeof resumeConfigs[0].onExitPlanModeRequest === 'function', resumeLargeOutput: resumeConfigs[0].largeOutput, resumeManagedSettings: resumeConfigs[0].managedSettings, + ephemeralMcpServers: createConfigs[1].mcpServers, + ephemeralDisabledMcpServers: createConfigs[1].disabledMcpServers, + ephemeralExcludedTools: createConfigs[1].excludedTools, }, { createClientName: 'vscode-agent-host', createGitHubMcpToolConfig: { disableFormDeferral: true }, @@ -512,6 +516,9 @@ suite('CopilotSessionLauncher shared session config', () => { resumeHasExitPlanHandler: true, resumeLargeOutput: { maxSizeBytes: 8192 }, resumeManagedSettings: { permissions: managedSettingsPermissions }, + ephemeralMcpServers: {}, + ephemeralDisabledMcpServers: ['azure', 'disabled-workspace-server', 'github', 'native-plugin-server', 'synced-server'], + ephemeralExcludedTools: ['task'], }); } finally { sessions.dispose(); diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 959b3098ce8fc3..89d98fca9d4b9c 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -14,6 +14,7 @@ import { SessionStatus } from '../../common/state/protocol/channels-session/stat import { buildChatUri, buildDefaultChatUri, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, withSessionOrchestration, type ISessionOrchestration, type ModelSelection, type ResponsePart, type ToolCallState, type Turn } from '../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; +import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { AgentServerToolHost } from '../../node/shared/agentServerToolHost.js'; import { applyCreateChatTool, @@ -78,6 +79,7 @@ suite('SessionServerTools', () => { test('definitions and confirmation', () => { assert.deepStrictEqual(sessionServerToolDefinitions.map(d => d.name), [SessionServerToolName.ListSessions, SessionServerToolName.GetCurrentSession, SessionServerToolName.CreateSession, SessionServerToolName.CreateChat, SessionServerToolName.RenameChat, SessionServerToolName.SendMessage, SessionServerToolName.GetSessionContext, SessionServerToolName.DeleteSession]); + assert.deepStrictEqual(sessionServerToolDefinitions.filter(definition => definition.enabledForEphemeralSessions).map(definition => definition.name), []); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.CreateSession), true); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.CreateChat), true); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.SendMessage), true); @@ -95,6 +97,28 @@ suite('SessionServerTools', () => { ]); }); + test('ephemeral sessions advertise no default session-management tools', () => { + const stateManager = new AgentHostStateManager(new NullLogService()); + const session = 'copilot:/ephemeral'; + stateManager.createSession({ + resource: session, + provider: 'copilot', + title: 'Ephemeral', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + _meta: withEphemeralSessionMeta(undefined, true), + }); + const host = new AgentServerToolHost(stateManager, [ + createSessionServerToolGroup(createAccessor()), + ]); + + host.advertise(session); + + assert.deepStrictEqual(stateManager.getSessionState(session)?.serverTools, []); + stateManager.dispose(); + }); + test('new sessions use the current setting while materialized sessions keep their advertised tools', async () => { let enabled = false; const stateManager = new AgentHostStateManager(new NullLogService()); diff --git a/src/vs/sessions/contrib/chat/browser/nullInlineChatSessionService.ts b/src/vs/sessions/contrib/chat/browser/nullInlineChatSessionService.ts index 28c4fd671ed334..6f8b31a16eb8b5 100644 --- a/src/vs/sessions/contrib/chat/browser/nullInlineChatSessionService.ts +++ b/src/vs/sessions/contrib/chat/browser/nullInlineChatSessionService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { URI } from '../../../../base/common/uri.js'; import { IActiveCodeEditor, ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; @@ -17,7 +18,7 @@ class NullInlineChatSessionService implements IInlineChatSessionService { dispose(): void { } - createSession(_editor: ICodeEditor): IInlineChatSession { + async createSession(_editor: ICodeEditor, _isNotebook: boolean, _token: CancellationToken): Promise { throw new Error('Inline chat sessions are not supported in the sessions window'); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 5aeb4a434b8211..3a85f5c12b826c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -284,7 +284,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr name: agentId, displayName: agent.displayName, description: agent.description, - locations: agent.provider === 'copilotcli' ? [ChatAgentLocation.Chat, ChatAgentLocation.Terminal] : undefined, + locations: agent.provider === 'copilotcli' ? [ChatAgentLocation.Chat, ChatAgentLocation.Terminal, ChatAgentLocation.EditorInline] : undefined, customAgentTarget: this._isSessionsWindow ? undefined : Target.GitHubCopilot, canDelegate: true, requiresCustomModels: true, 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 75d1998716f01d..289bb383c9bc3f 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -2308,6 +2308,15 @@ configurationRegistry.registerConfiguration({ mode: 'startup' } }, + [ChatConfiguration.InlineChatAgentHostEnabled]: { + type: 'boolean', + description: nls.localize('chat.inlineChat.agentHost.enabled', "Controls whether editor inline chat is backed by the Agent Host instead of the extension host. Applied on startup."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'startup' + } + }, [ChatConfiguration.CollectInstructionsInExtension]: { type: 'boolean', description: nls.localize('chat.experimental.collectInstructionsInExtension', "When enabled, automatic instruction collection (.instructions.md, agent instructions, customizations index) is performed by the GitHub Copilot Chat extension instead of the core workbench."), diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts index 8233bd804f5478..01fe8016fe5502 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts @@ -28,7 +28,7 @@ import { MultiDiffEditor } from '../../../multiDiffEditor/browser/multiDiffEdito import { IDocumentDiffItemWithMultiDiffEditorItem, MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js'; import { NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_EDITOR_FOCUSED } from '../../../notebook/common/notebookContextKeys.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatEditingService, IChatEditingSession, IModifiedFileEntry, IModifiedFileEntryChangeHunk, IModifiedFileEntryEditorIntegration, ModifiedFileEntryState, parseChatMultiDiffUri, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME } from '../../common/editing/chatEditingService.js'; +import { IChatEditReviewSession, IChatEditingService, IModifiedFileEntry, IModifiedFileEntryChangeHunk, IModifiedFileEntryEditorIntegration, ModifiedFileEntryState, parseChatMultiDiffUri, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME } from '../../common/editing/chatEditingService.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { ctxCursorInChangeRange, ctxHasEditorModification, ctxHasRequestInProgress, ctxIsCurrentlyBeingModified, ctxIsGlobalEditingSession, ctxReviewModeEnabled } from './chatEditingEditorContextKeys.js'; import { ChatEditingExplanationWidgetManager } from './chatEditingExplanationWidget.js'; @@ -76,7 +76,7 @@ abstract class ChatEditingEditorAction extends Action2 { return instaService.invokeFunction(this.runChatEditingCommand.bind(this), session, entry, ctrl, ...args); } - abstract runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditingSession, entry: IModifiedFileEntry, integration: IModifiedFileEntryEditorIntegration, ...args: unknown[]): Promise | void; + abstract runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditReviewSession, entry: IModifiedFileEntry, integration: IModifiedFileEntryEditorIntegration, ...args: unknown[]): Promise | void; } abstract class NavigateAction extends ChatEditingEditorAction { @@ -111,7 +111,7 @@ abstract class NavigateAction extends ChatEditingEditorAction { }); } - override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditingSession, entry: IModifiedFileEntry, ctrl: IModifiedFileEntryEditorIntegration): Promise { + override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditReviewSession, entry: IModifiedFileEntry, ctrl: IModifiedFileEntryEditorIntegration): Promise { const instaService = accessor.get(IInstantiationService); @@ -135,7 +135,7 @@ abstract class NavigateAction extends ChatEditingEditorAction { } } -async function openNextOrPreviousChange(accessor: ServicesAccessor, session: IChatEditingSession, entry: IModifiedFileEntry, next: boolean) { +async function openNextOrPreviousChange(accessor: ServicesAccessor, session: IChatEditReviewSession, entry: IModifiedFileEntry, next: boolean) { const editorService = accessor.get(IEditorService); @@ -208,7 +208,7 @@ abstract class KeepOrUndoAction extends ChatEditingEditorAction { }); } - override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditingSession, entry: IModifiedFileEntry, _integration: IModifiedFileEntryEditorIntegration): Promise { + override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditReviewSession, entry: IModifiedFileEntry, _integration: IModifiedFileEntryEditorIntegration): Promise { const instaService = accessor.get(IInstantiationService); const configService = accessor.get(IConfigurationService); @@ -270,7 +270,7 @@ abstract class AcceptRejectHunkAction extends ChatEditingEditorAction { ); } - override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditingSession, entry: IModifiedFileEntry, ctrl: IModifiedFileEntryEditorIntegration, ...args: unknown[]): Promise { + override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditReviewSession, entry: IModifiedFileEntry, ctrl: IModifiedFileEntryEditorIntegration, ...args: unknown[]): Promise { const instaService = accessor.get(IInstantiationService); const configService = accessor.get(IConfigurationService); @@ -335,7 +335,7 @@ class ToggleDiffAction extends ChatEditingEditorAction { }); } - override runChatEditingCommand(_accessor: ServicesAccessor, _session: IChatEditingSession, _entry: IModifiedFileEntry, integration: IModifiedFileEntryEditorIntegration, ...args: unknown[]): Promise | void { + override runChatEditingCommand(_accessor: ServicesAccessor, _session: IChatEditReviewSession, _entry: IModifiedFileEntry, integration: IModifiedFileEntryEditorIntegration, ...args: unknown[]): Promise | void { integration.toggleDiff(args[0] as IModifiedFileEntryChangeHunk | undefined); } } @@ -355,7 +355,7 @@ class ToggleAccessibleDiffViewAction extends ChatEditingEditorAction { }); } - override runChatEditingCommand(_accessor: ServicesAccessor, _session: IChatEditingSession, _entry: IModifiedFileEntry, integration: IModifiedFileEntryEditorIntegration): Promise | void { + override runChatEditingCommand(_accessor: ServicesAccessor, _session: IChatEditReviewSession, _entry: IModifiedFileEntry, integration: IModifiedFileEntryEditorIntegration): Promise | void { integration.enableAccessibleDiffView(); } } @@ -376,7 +376,7 @@ export class ReviewChangesAction extends ChatEditingEditorAction { }); } - override runChatEditingCommand(_accessor: ServicesAccessor, _session: IChatEditingSession, entry: IModifiedFileEntry, _integration: IModifiedFileEntryEditorIntegration, ..._args: unknown[]): void { + override runChatEditingCommand(_accessor: ServicesAccessor, _session: IChatEditReviewSession, entry: IModifiedFileEntry, _integration: IModifiedFileEntryEditorIntegration, ..._args: unknown[]): void { entry.enableReviewModeUntilSettled(); } } @@ -401,7 +401,7 @@ export class AcceptAllEditsAction extends ChatEditingEditorAction { }); } - override async runChatEditingCommand(_accessor: ServicesAccessor, session: IChatEditingSession, _entry: IModifiedFileEntry, _integration: IModifiedFileEntryEditorIntegration, ..._args: unknown[]): Promise { + override async runChatEditingCommand(_accessor: ServicesAccessor, session: IChatEditReviewSession, _entry: IModifiedFileEntry, _integration: IModifiedFileEntryEditorIntegration, ..._args: unknown[]): Promise { await session.accept(); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorContextKeys.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorContextKeys.ts index 2bf6ec47bb09d2..9ccaac1257658c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorContextKeys.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorContextKeys.ts @@ -13,7 +13,7 @@ import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; import { IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IInlineChatSessionService } from '../../../inlineChat/browser/inlineChatSessionService.js'; -import { IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; +import { IChatEditReviewSession, IChatEditingService, IModifiedFileEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatService } from '../../common/chatService/chatService.js'; export const ctxIsGlobalEditingSession = new RawContextKey('chatEdits.isGlobalEditingSession', undefined, localize('chat.ctxEditSessionIsGlobal', "The current editor is part of the global edit session")); @@ -149,7 +149,7 @@ class ContextKeyGroup { export class ObservableEditorSession { - readonly value: IObservable; + readonly value: IObservable; constructor( uri: URI, diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts index 35ddb2477e1722..6817b7dc72aa04 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts @@ -8,7 +8,7 @@ import { combinedDisposable, Disposable, DisposableMap, DisposableStore, Mutable import { autorun, derived, derivedOpts, IObservable, observableFromEvent, observableSignalFromEvent, observableValue, transaction } from '../../../../../base/common/observable.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; +import { IChatEditReviewSession, IChatEditingService, IModifiedFileEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; import { ActionViewItem, IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction, IActionRunner } from '../../../../../base/common/actions.js'; @@ -108,7 +108,7 @@ class ChatEditorOverlayWidget extends Disposable { private readonly _showStore = this._store.add(new DisposableStore()); - private readonly _session = observableValue(this, undefined); + private readonly _session = observableValue(this, undefined); private readonly _entry = observableValue(this, undefined); private readonly _isBusy: IObservable; @@ -156,7 +156,7 @@ class ChatEditorOverlayWidget extends Disposable { return this._domNode; } - show(session: IChatEditingSession, entry: IModifiedFileEntry | undefined, indicies: { entryIndex: IObservable; changeIndex: IObservable }) { + show(session: IChatEditReviewSession, entry: IModifiedFileEntry | undefined, indicies: { entryIndex: IObservable; changeIndex: IObservable }) { this._showStore.clear(); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts index dee9c180fcec55..c0b57e5fe30f3d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts @@ -37,7 +37,7 @@ import { ILifecycleService } from '../../../../services/lifecycle/common/lifecyc import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js'; import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { INotebookService } from '../../../notebook/common/notebookService.js'; -import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, ChatEditingSessionState, IChatEditingService, IChatEditingSession, IChatEditingSessionProvider, IModifiedFileEntry, inChatEditingSessionContextKey, IStreamingEdits, ModifiedFileEntryState, parseChatMultiDiffUri } from '../../common/editing/chatEditingService.js'; +import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, IChatEditReviewSession, IChatEditingService, IChatEditingSession, IChatEditingSessionProvider, IModifiedFileEntry, inChatEditingSessionContextKey, IStreamingEdits, ModifiedFileEntryState, parseChatMultiDiffUri } from '../../common/editing/chatEditingService.js'; import { ChatModel, ICellTextEditOperation, IChatResponseModel, isCellTextEditOperationArray } from '../../common/model/chatModel.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -52,9 +52,10 @@ export class ChatEditingService extends Disposable implements IChatEditingServic private readonly _providers = new Map(); - private readonly _sessionsObs = observableValueOpts>({ equalsFn: (a, b) => false }, new LinkedList()); + private readonly _sessionsObs = observableValueOpts>({ equalsFn: (a, b) => false }, new LinkedList()); + private readonly _editingSessions = new ResourceMap(); - readonly editingSessionsObs: IObservable = derived(r => { + readonly editingSessionsObs: IObservable = derived(r => { const result = Array.from(this._sessionsObs.read(r)); return result; }); @@ -78,7 +79,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic ) { super(); this._register(decorationsService.registerDecorationsProvider(_instantiationService.createInstance(ChatDecorationsProvider, this.editingSessionsObs))); - this._register(multiDiffSourceResolverService.registerResolver(_instantiationService.createInstance(ChatEditingMultiDiffSourceResolver, this.editingSessionsObs))); + this._register(multiDiffSourceResolverService.registerResolver(_instantiationService.createInstance(ChatEditingMultiDiffSourceResolver, this.editingSessionsObs, resource => this.getEditingSession(resource)))); // TODO@jrieken // some ugly casting so that this service can pass itself as argument instad as service dependeny @@ -113,7 +114,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic // eslint-disable-next-line @typescript-eslint/no-explicit-any const tasks: Promise[] = []; - for (const session of this.editingSessionsObs.get()) { + for (const session of this._editingSessions.values()) { if (!session.isGlobalEditingSession) { continue; } @@ -137,7 +138,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic } override dispose(): void { - dispose(this._sessionsObs.get()); + dispose(this._editingSessions.values()); super.dispose(); } @@ -158,8 +159,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic } getEditingSession(chatSessionResource: URI): IChatEditingSession | undefined { - return this.editingSessionsObs.get() - .find(candidate => isEqual(candidate.chatSessionResource, chatSessionResource)); + return this._editingSessions.get(chatSessionResource); } createEditingSession(chatModel: ChatModel, global: boolean = false): IChatEditingSession { @@ -181,6 +181,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic const list = this._sessionsObs.get(); const removeSession = list.unshift(session); + this._editingSessions.set(session.chatSessionResource, session); const store = new DisposableStore(); this._store.add(store); @@ -191,6 +192,9 @@ export class ChatEditingService extends Disposable implements IChatEditingServic store.add(session.onDidDispose(e => { removeSession(); + if (this._editingSessions.get(session.chatSessionResource) === session) { + this._editingSessions.delete(session.chatSessionResource); + } this._sessionsObs.set(list, undefined); this._store.delete(store); })); @@ -200,6 +204,17 @@ export class ChatEditingService extends Disposable implements IChatEditingServic return session; } + registerEditReviewSession(session: IChatEditReviewSession): IDisposable { + const list = this._sessionsObs.get(); + const removeSession = list.unshift(session); + this._sessionsObs.set(list, undefined); + + return toDisposable(() => { + removeSession(); + this._sessionsObs.set(list, undefined); + }); + } + registerEditingSessionProvider(scheme: string, provider: IChatEditingSessionProvider): IDisposable { this._providers.set(scheme, provider); return toDisposable(() => { @@ -393,10 +408,8 @@ class ChatDecorationsProvider extends Disposable implements IDecorationsProvider } const result: IModifiedFileEntry[] = []; for (const session of sessions) { - if (session.state.read(r) !== ChatEditingSessionState.Disposed) { - const entries = session.entries.read(r); - result.push(...entries); - } + const entries = session.entries.read(r); + result.push(...entries); } return result; }); @@ -414,7 +427,7 @@ class ChatDecorationsProvider extends Disposable implements IDecorationsProvider readonly onDidChange: Event; constructor( - private readonly _sessions: IObservable + private readonly _sessions: IObservable ) { super(); this.onDidChange = Event.any( @@ -448,7 +461,8 @@ class ChatDecorationsProvider extends Disposable implements IDecorationsProvider export class ChatEditingMultiDiffSourceResolver implements IMultiDiffSourceResolver { constructor( - private readonly _editingSessionsObs: IObservable, + private readonly _editingSessionsObs: IObservable, + private readonly _getEditingSession: (chatSessionResource: URI) => IChatEditingSession | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { } @@ -460,7 +474,8 @@ export class ChatEditingMultiDiffSourceResolver implements IMultiDiffSourceResol const parsed = parseChatMultiDiffUri(uri); const thisSession = derived(this, r => { - return this._editingSessionsObs.read(r).find(candidate => isEqual(candidate.chatSessionResource, parsed.chatSessionResource)); + this._editingSessionsObs.read(r); + return this._getEditingSession(parsed.chatSessionResource); }); return this._instantiationService.createInstance(ChatEditingMultiDiffSource, thisSession, parsed.showPreviousChanges); diff --git a/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts b/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts index 5cf676a4c74911..0d593c063a2215 100644 --- a/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts +++ b/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts @@ -142,7 +142,8 @@ export class PromptTimelineModel extends Disposable { if (!resource) { return undefined; } - return this.chatEditingService.editingSessionsObs.read(reader).find(s => isEqual(s.chatSessionResource, resource)); + this.chatEditingService.editingSessionsObs.read(reader); + return this.chatEditingService.getEditingSession(resource); }); /** Recency-bucketed ticks, capped to a fixed maximum so each keeps a >=24px slot. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 3c32979fd5597b..322ec7b0222abf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -707,9 +707,9 @@ export class ChatWidget extends Disposable implements IChatWidget { this._register(autorun(r => { const viewModel = viewModelObs.read(r); - const sessions = chatEditingService.editingSessionsObs.read(r); + chatEditingService.editingSessionsObs.read(r); - const session = sessions.find(candidate => isEqual(candidate.chatSessionResource, viewModel?.sessionResource)); + const session = viewModel ? chatEditingService.getEditingSession(viewModel.sessionResource) : undefined; this._editingSession.set(undefined, undefined); this.renderChatEditingSessionState(); // this is necessary to make sure we dispose previous buttons, etc. diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index ec9b12a291f5fb..b7205ea6d3c378 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -275,7 +275,7 @@ export class ChatService extends Disposable implements IChatService { logChangesToStateModel(model.inputModel, `disposing session ${model.sessionResource} (${localSessionId}) with title, storing to storage`, undefined, undefined, this.logService); await this._chatSessionStore.storeSessions([model]); } - } else if (!localSessionId && (model.getRequests().length > 0 || hasDraftInput(model))) { + } else if (!localSessionId && this.shouldStoreExternalSession(model) && (model.getRequests().length > 0 || hasDraftInput(model))) { logChangesToStateModel(model.inputModel, `disposing external session ${model.sessionResource} with requests or draft input, storing metadata to storage`, undefined, undefined, this.logService); // External sessions: persist metadata when there are requests, OR when the // user has typed/attached unsent input we need to restore on next open. @@ -346,7 +346,7 @@ export class ChatService extends Disposable implements IChatService { .filter(session => this.shouldStoreSession(session)); const liveNonLocalChats = Array.from(this._sessionModels.values()) - .filter(session => !LocalChatSessionUri.parseLocalSessionId(session.sessionResource)); + .filter(session => this.shouldStoreExternalSession(session)); // Synchronously update the index for all live sessions and flush it to // storage. This is critical because `onWillSaveState` is synchronous — @@ -374,6 +374,18 @@ export class ChatService extends Disposable implements IChatService { return session.initialLocation === ChatAgentLocation.Chat && !session.isImported; } + /** + * Only persist external (provider-backed) sessions that belong to chat. + * Transient surfaces such as inline chat and terminal chat create throwaway + * sessions that must never show up in chat history. + */ + private shouldStoreExternalSession(session: ChatModel): boolean { + if (LocalChatSessionUri.parseLocalSessionId(session.sessionResource)) { + return false; + } + return session.initialLocation === ChatAgentLocation.Chat; + } + notifyUserAction(action: IChatUserActionEvent): void { this._chatServiceTelemetry.notifyUserAction(action); this._onDidPerformUserAction.fire(action); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index f1c53c5742d279..8fff0edb4e452b 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -62,6 +62,7 @@ export enum ChatConfiguration { ThinkingGenerateTitles = 'chat.agent.thinking.generateTitles', TerminalToolsInThinking = 'chat.agent.thinking.terminalTools', TerminalAgentHostEnabled = 'chat.terminal.agentHost.enabled', + InlineChatAgentHostEnabled = 'chat.inlineChat.agentHost.enabled', CollapseCompletedResponses = 'chat.agent.collapseCompletedResponses', SimpleTerminalCollapsible = 'chat.tools.terminal.simpleCollapsible', CompressOutputEnabled = 'chat.tools.compressOutput.enabled', diff --git a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts index 03ca49e8633e73..92e773e0d65050 100644 --- a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts @@ -41,13 +41,19 @@ export interface IChatEditingService { /** * All editing sessions, sorted by recency, e.g the last created session comes first. */ - readonly editingSessionsObs: IObservable; + readonly editingSessionsObs: IObservable; /** * Creates a new short lived editing session */ createEditingSession(chatModel: ChatModel): IChatEditingSession; + /** + * Registers a review session that was not created via {@link createEditingSession}, + * so editor-level review UI can discover its entries. Disposing the result removes it. + */ + registerEditReviewSession(session: IChatEditReviewSession): IDisposable; + /** * Creates an editing session with state transferred from the provided session. */ @@ -99,22 +105,31 @@ export interface ISnapshotEntry { readonly isDeleted?: boolean; } -export interface IChatEditingSession extends IDisposable { +/** + * A reviewable set of file changes: entries with editor decorations that can + * be kept or undone. This is the minimal surface that editor-level review UI + * (decorations, hunk navigation, keep/undo actions) depends on. Full chat + * editing sessions additionally support checkpoints, streaming edits, storage + * and multi-diff via {@link IChatEditingSession}. + */ +export interface IChatEditReviewSession extends IDisposable { readonly isGlobalEditingSession: boolean; - readonly supportsKeepUndo: boolean; readonly chatSessionResource: URI; readonly onDidDispose: Event; - readonly state: IObservable; readonly entries: IObservable; + getEntry(uri: URI): IModifiedFileEntry | undefined; + readEntry(uri: URI, reader: IReader): IModifiedFileEntry | undefined; + accept(...uris: URI[]): Promise; + reject(...uris: URI[]): Promise; +} + +export interface IChatEditingSession extends IChatEditReviewSession { + readonly supportsKeepUndo: boolean; + readonly state: IObservable; /** Requests disabled by undo/redo in the session */ readonly requestDisablement: IObservable; show(previousChanges?: boolean): Promise; - accept(...uris: URI[]): Promise; - reject(...uris: URI[]): Promise; - getEntry(uri: URI): IModifiedFileEntry | undefined; - readEntry(uri: URI, reader: IReader): IModifiedFileEntry | undefined; - restoreSnapshot(requestId: string, stopId: string | undefined): Promise; /** diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index ccf26a7421e416..9739d0d14929fe 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Event } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; -import { waitForState } from '../../../../../../base/common/observable.js'; +import { autorun, IReader, observableValue, waitForState } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { assertType } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -37,8 +37,8 @@ import { INotebookService } from '../../../../notebook/common/notebookService.js import { ChatEditingService } from '../../../browser/chatEditing/chatEditingServiceImpl.js'; import { ChatSessionsService } from '../../../browser/chatSessions/chatSessions.contribution.js'; import { ChatAgentService, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; -import { ChatModel } from '../../../common/model/chatModel.js'; +import { ChatEditingSessionState, IChatEditReviewSession, IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; +import { ChatModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; @@ -150,6 +150,42 @@ suite('ChatEditingService', function () { ensureNoDisposablesAreLeakedInTestSuite(); + function createReviewSession(entry: IModifiedFileEntry): IChatEditReviewSession { + return store.add(new class extends Disposable implements IChatEditReviewSession { + private readonly onDidDisposeEmitter = this._register(new Emitter()); + + readonly isGlobalEditingSession = false; + readonly chatSessionResource = URI.parse('test://review-session'); + readonly onDidDispose = this.onDidDisposeEmitter.event; + readonly entries = observableValue('entries', [entry]); + + override dispose(): void { + this.onDidDisposeEmitter.fire(); + super.dispose(); + } + + getEntry(uri: URI): IModifiedFileEntry | undefined { + return isEqual(uri, entry.modifiedURI) ? entry : undefined; + } + + readEntry(uri: URI, _reader: IReader): IModifiedFileEntry | undefined { + return this.getEntry(uri); + } + + async accept(..._uris: URI[]): Promise { } + + async reject(..._uris: URI[]): Promise { } + }); + } + + function createReviewEntry(uri: URI): IModifiedFileEntry { + return new class extends mock() { + override readonly modifiedURI = uri; + override readonly isCurrentlyBeingModifiedBy = observableValue<{ responseModel: IChatResponseModel; undoStopId: string | undefined } | undefined>('isCurrentlyBeingModifiedBy', undefined); + override readonly state = observableValue('state', ModifiedFileEntryState.Modified); + }; + } + test('create session', async function () { assert.ok(editingService); @@ -169,6 +205,39 @@ suite('ChatEditingService', function () { modelRef.dispose(); }); + test('register edit review session', () => { + const entry = createReviewEntry(URI.parse('test://review-entry')); + const session = createReviewSession(entry); + const registration = store.add(editingService.registerEditReviewSession(session)); + + assert.deepStrictEqual(editingService.editingSessionsObs.get(), [session]); + + registration.dispose(); + + assert.deepStrictEqual(editingService.editingSessionsObs.get(), []); + }); + + test('registered edit review session is not returned as an editing session', () => { + const session = createReviewSession(createReviewEntry(URI.parse('test://review-entry'))); + store.add(editingService.registerEditReviewSession(session)); + + assert.strictEqual(editingService.getEditingSession(session.chatSessionResource), undefined); + }); + + test('registered edit review entries are discoverable', () => { + const entry = createReviewEntry(URI.parse('test://review-entry')); + store.add(editingService.registerEditReviewSession(createReviewSession(entry))); + + let discoveredEntry: IModifiedFileEntry | undefined; + store.add(autorun(reader => { + discoveredEntry = editingService.editingSessionsObs.read(reader) + .find(session => session.getEntry(entry.modifiedURI)) + ?.readEntry(entry.modifiedURI, reader); + })); + + assert.strictEqual(discoveredEntry, entry); + }); + test('create session, file entry & isCurrentlyBeingModifiedBy', async function () { assert.ok(editingService); 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 4c25631e0dac8e..fa9cb0decffdc1 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 @@ -1605,6 +1605,53 @@ suite('ChatService', () => { assert.ok(lastThree[2].includes('queued-3')); }); + test('external sessions from transient surfaces are not persisted to chat history (inline chat)', async () => { + // Inline chat and terminal chat create throwaway agent-host sessions. Their + // resources are not local, so they used to fall into the external-session + // persistence path and show up in the chat session list. + const remoteScheme = 'transient-surface-provider'; + + const mockSessionsService = new MockChatSessionsService(); + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(remoteScheme, { + provideChatSessionContent: (resource: URI) => Promise.resolve({ + sessionResource: resource, + history: [], + onWillDispose: Event.None, + dispose: () => { }, + }), + })); + instantiationService.stub(IChatSessionsService, mockSessionsService); + + const agent: IChatAgentImplementation = { async invoke() { return {}; } }; + testDisposables.add(chatAgentService.registerAgent(remoteScheme, { ...getAgentData(remoteScheme), locations: [ChatAgentLocation.Chat, ChatAgentLocation.EditorInline], isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(remoteScheme, agent)); + + const testService = createChatService(); + + const send = async (resource: URI, location: ChatAgentLocation) => { + const ref = await testService.acquireOrLoadSession(resource, location, CancellationToken.None); + assert.ok(ref); + const response = await testService.sendRequest(resource, 'hello', { agentId: remoteScheme }); + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; + ref.dispose(); + }; + + const inlineResource = URI.from({ scheme: remoteScheme, path: '/inline-session' }); + const panelResource = URI.from({ scheme: remoteScheme, path: '/panel-session' }); + await send(inlineResource, ChatAgentLocation.EditorInline); + await send(panelResource, ChatAgentLocation.Chat); + await Promise.all(testServices.map(service => service.waitForModelDisposals())); + + assert.deepStrictEqual( + { + inline: !!await testService.getMetadataForSession(inlineResource), + panel: !!await testService.getMetadataForSession(panelResource), + }, + { inline: false, panel: true } + ); + }); + test('acquireOrLoadSession returns undefined when remote provider is not registered (fix for #301203)', async () => { const unregisteredScheme = 'unregistered-provider'; const sessionResource = URI.from({ scheme: unregisteredScheme, path: '/orphaned-session' }); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index f9d4acd27aea09..10cffaa118ef24 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -12,6 +12,7 @@ import { InlineChatNotebookContribution } from './inlineChatNotebook.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { IInlineChatSessionService } from './inlineChatSessionService.js'; import { InlineChatEnabler, InlineChatEscapeToolContribution, InlineChatSessionServiceImpl } from './inlineChatSessionServiceImpl.js'; +import { IInlineChatSessionResolver, InlineChatSessionResolver } from './inlineChatSessionResolver.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { InlineChatAccessibilityHelp } from './inlineChatAccessibilityHelp.js'; import { InlineChatDefaultModel } from './inlineChatDefaultModel.js'; @@ -27,6 +28,7 @@ registerAction2(InlineChatActions.RephraseInlineChatSessionAction); // --- browser registerSingleton(IInlineChatSessionService, InlineChatSessionServiceImpl, InstantiationType.Delayed); +registerSingleton(IInlineChatSessionResolver, InlineChatSessionResolver, InstantiationType.Delayed); // --- actions --- diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 2f4eaed184c952..cae8e7fc81db63 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -5,10 +5,11 @@ import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { alert } from '../../../../base/browser/ui/aria/aria.js'; -import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationError, isCancellationError, onUnexpectedError } from '../../../../base/common/errors.js'; import { Event } from '../../../../base/common/event.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, IObservable, observableFromEvent, observableSignalFromEvent, observableValue, waitForState } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { assertType } from '../../../../base/common/types.js'; @@ -25,7 +26,7 @@ import { IMarkerDecorationsService } from '../../../../editor/common/services/ma import { localize } from '../../../../nls.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; @@ -46,6 +47,7 @@ import { InlineChatAffordance } from './inlineChatAffordance.js'; import { continueInPanelChat, IInlineChatSession, IInlineChatSessionService, rephraseInlineChat } from './inlineChatSessionService.js'; import { EditorBasedInlineChatWidget } from './inlineChatWidget.js'; import { InlineChatZoneWidget } from './inlineChatZoneWidget.js'; +import { IMarkdownString } from '../../../../base/common/htmlContent.js'; export abstract class InlineChatRunOptions { @@ -104,6 +106,7 @@ export class InlineChatController implements IEditorContribution { static #userSelectedModel: string | undefined; readonly #store = new DisposableStore(); + readonly #pendingSessionCts = new MutableDisposable(); readonly #isActiveController = observableValue(this, false); readonly #zone: Lazy; readonly inputOverlayWidget: InlineChatAffordance; @@ -121,6 +124,7 @@ export class InlineChatController implements IEditorContribution { readonly #logService: ILogService; readonly #chatEditingService: IChatEditingService; readonly #chatService: IChatService; + readonly #ctxInlineChatVisible: IContextKey; get widget(): EditorBasedInlineChatWidget { return this.#zone.value.widget; @@ -158,13 +162,17 @@ export class InlineChatController implements IEditorContribution { this.#chatService = chatService; const editorObs = observableCodeEditor(editor); + let agentHostAttachmentId: string | undefined; + let agentHostAttachmentChanges: IObservable | undefined; - const ctxInlineChatVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(contextKeyService); + this.#ctxInlineChatVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(contextKeyService); + this.#store.add(this.#pendingSessionCts); const ctxFileBelongsToChat = CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.bindTo(contextKeyService); const ctxTerminated = CTX_INLINE_CHAT_TERMINATED.bindTo(contextKeyService); const notebookAgentConfig = observableConfigValue(InlineChatConfigKeys.NotebookAgent, false, this.#configurationService); // Track whether the current editor's file is being edited by any chat editing session + let initializedZone = false; this.#store.add(autorun(r => { const model = editorObs.model.read(r); if (!model) { @@ -254,6 +262,7 @@ export class InlineChatController implements IEditorContribution { ); this.#store.add(result); + agentHostAttachmentChanges = observableSignalFromEvent(this, result.widget.chatWidget.attachmentModel.onDidChange); result.domNode.classList.add('inline-chat-2'); @@ -275,6 +284,7 @@ export class InlineChatController implements IEditorContribution { this.#store.add(autorun(r => { const session = this.#currentSession.read(r); if (!session) { + this.#cancelPendingSession(); this.#isActiveController.set(false, undefined); if (lastSession && !lastSession.chatModel.hasRequests) { @@ -334,16 +344,26 @@ export class InlineChatController implements IEditorContribution { // HIDE/SHOW const session = visibleSessionObs.read(r); if (!session) { + initializedZone = false; this.#zone.rawValue?.hide(); this.#zone.rawValue?.widget.chatWidget.setModel(undefined); editor.focus(); - ctxInlineChatVisible.reset(); + this.#ctxInlineChatVisible.reset(); } else { - ctxInlineChatVisible.set(true); + this.#ctxInlineChatVisible.set(true); this.#zone.value.widget.chatWidget.setModel(session.chatModel); - if (!this.#zone.value.position) { + const lockToAgent = session.lockToAgent; + if (lockToAgent) { + this.#zone.value.widget.chatWidget.lockToCodingAgent(lockToAgent.name, lockToAgent.displayName, lockToAgent.type, lockToAgent.agentHostProviderId); + } else { + this.#zone.value.widget.chatWidget.unlockFromCodingAgent(); + } + if (!initializedZone) { this.#zone.value.widget.chatWidget.setInputPlaceholder(defaultPlaceholderObs.read(r)); this.#zone.value.widget.chatWidget.input.renderAttachedContext(); // TODO - fights layout bug + initializedZone = true; + } + if (!this.#zone.value.position) { this.#zone.value.show(session.initialPosition); } this.#zone.value.reveal(this.#zone.value.position!); @@ -351,6 +371,25 @@ export class InlineChatController implements IEditorContribution { } })); + this.#store.add(autorun(r => { + const session = visibleSessionObs.read(r); + const model = editorObs.model.read(r); + if (!session?.lockToAgent || !model) { + if (agentHostAttachmentId) { + this.#zone.rawValue?.widget.chatWidget.attachmentModel.updateContext([agentHostAttachmentId], []); + agentHostAttachmentId = undefined; + } + return; + } + + const attachmentModel = this.#zone.value.widget.chatWidget.attachmentModel; + agentHostAttachmentChanges?.read(r); + const selection = editorObs.cursorSelection.read(r); + const entry = attachmentModel.asFileVariableEntry(model.uri, selection?.isEmpty() ? undefined : selection ?? undefined); + attachmentModel.updateContext(agentHostAttachmentId && agentHostAttachmentId !== entry.id ? [agentHostAttachmentId] : [], [entry]); + agentHostAttachmentId = entry.id; + })); + // Auto-approve tool confirmations for inline chat. The user implicitly // consents to editing the current file by invoking inline chat on it, // even if the file qualifies as a sensitive file. @@ -407,7 +446,9 @@ export class InlineChatController implements IEditorContribution { if (!response) { return; } - return observableFromEvent(this, response.onDidChange, () => response.response.value.findLast(part => part.kind === 'progressMessage')).read(r); + return observableFromEvent(this, response.onDidChange, () => response.response.value.findLast(part => + part.kind === 'progressMessage' || part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized' + )).read(r); }); @@ -441,12 +482,32 @@ export class InlineChatController implements IEditorContribution { } else { this.#zone.rawValue?.widget.domNode.classList.toggle('request-in-progress', true); this.#zone.rawValue?.status.set('', undefined); - let placeholder = response.request?.message.text; - const lastProgress = lastResponseProgressObs.read(r); - if (lastProgress) { - placeholder = renderAsPlaintext(lastProgress.content); - } - this.#zone.rawValue?.widget.chatWidget.setInputPlaceholder(placeholder || localize('loading', "Working...")); + r.store.add(autorun(r => { + let placeholder: string | IMarkdownString | undefined = response.request?.message.text; + const lastProgress = lastResponseProgressObs.read(r); + if (lastProgress?.kind === 'progressMessage') { + placeholder = lastProgress.content; + } else if (lastProgress?.kind === 'toolInvocationSerialized') { + placeholder = lastProgress.invocationMessage ?? lastProgress.pastTenseMessage; + } else if (lastProgress?.kind === 'toolInvocation') { + const state = lastProgress.state.read(r); + if (state.type === IChatToolInvocation.StateKind.Executing) { + placeholder = state.progress.read(r).message; + } else if (state.type === IChatToolInvocation.StateKind.Streaming) { + placeholder = state.streamingMessage.read(r); + } else if (state.type === IChatToolInvocation.StateKind.Completed || state.type === IChatToolInvocation.StateKind.Cancelled) { + placeholder = lastProgress.pastTenseMessage; + } else { + placeholder = lastProgress.invocationMessage; + } + } + + // Tool progress messages reference files as empty-text markdown links + // (`[](file:///…)`) for historical reasons; the link formatter substitutes + // the file's basename so the placeholder reads naturally. + const value = typeof placeholder === 'string' ? placeholder : (placeholder ? renderAsPlaintext(placeholder, { useLinkFormatter: true }) : localize('loading', "Working...")); + this.#zone.rawValue?.widget.chatWidget.setInputPlaceholder(value); + })); } })); @@ -465,15 +526,28 @@ export class InlineChatController implements IEditorContribution { this.#store.add(autorun(r => { + const model = editorObs.model.read(r); + const pane = this.#editorService.visibleEditorPanes.find(candidate => candidate.getControl() === this.#editor || isNotebookWithCellEditor(candidate, this.#editor)); + if (!model || !pane) { + return; + } - const session = visibleSessionObs.read(r); - const entry = session?.editingSession.readEntry(session.uri, r); + for (const session of this.#chatEditingService.editingSessionsObs.read(r)) { + if (session.isGlobalEditingSession) { + continue; + } - // make sure there is an editor integration - const pane = this.#editorService.visibleEditorPanes.find(candidate => candidate.getControl() === this.#editor || isNotebookWithCellEditor(candidate, this.#editor)); - if (pane && entry) { - entry?.getEditorIntegration(pane); + const entry = session.readEntry(model.uri, r); + if (entry) { + entry.getEditorIntegration(pane); + return; + } } + })); + + this.#store.add(autorun(r => { + const session = visibleSessionObs.read(r); + const entry = session?.editingSession.readEntry(session.uri, r); // make sure the ZONE isn't inbetween a diff and move above if so if (entry?.diffInfo && this.#zone.rawValue?.position) { @@ -491,6 +565,7 @@ export class InlineChatController implements IEditorContribution { } dispose(): void { + this.#cancelPendingSession(); this.#store.dispose(); } @@ -504,6 +579,7 @@ export class InlineChatController implements IEditorContribution { async run(arg?: InlineChatRunOptions): Promise { assertType(this.#editor.hasModel()); + this.#cancelPendingSession(); const uri = this.#editor.getModel().uri; const existingSession = this.#inlineChatSessionService.getSessionByTextModel(uri); @@ -513,9 +589,53 @@ export class InlineChatController implements IEditorContribution { } this.#isActiveController.set(true, undefined); + this.#ctxInlineChatVisible.set(true); + + const initialPosition = this.#editor.getSelection().getStartPosition().delta(-1); + this.#zone.value.show(initialPosition); + this.#zone.value.widget.focus(); + const sessionCts = new CancellationTokenSource(); + this.#pendingSessionCts.value = sessionCts; + try { + const session = await this.#inlineChatSessionService.createSession(this.#editor, !!this.#notebookEditorService.getNotebookForPossibleCell(this.#editor), sessionCts.token); + if (sessionCts.token.isCancellationRequested) { + session.dispose(); + throw new CancellationError(); + } + if (this.#pendingSessionCts.value === sessionCts) { + this.#pendingSessionCts.clear(); + } + return this.#runZone(session, arg); + } catch (error) { + if (isCancellationError(error)) { + if (this.#pendingSessionCts.value === sessionCts) { + this.#cancelPendingSessionAndHide(); + } + return false; + } + throw error; + } finally { + if (this.#pendingSessionCts.value === sessionCts) { + this.#pendingSessionCts.clear(); + } + } + } + + #cancelPendingSession(): void { + const sessionCts = this.#pendingSessionCts.value; + if (sessionCts) { + sessionCts.cancel(); + this.#pendingSessionCts.clear(); + } + } - const session = this.#inlineChatSessionService.createSession(this.#editor); - return this.#runZone(session, arg); + #cancelPendingSessionAndHide(): void { + this.#cancelPendingSession(); + this.#isActiveController.set(false, undefined); + this.#zone.rawValue?.hide(); + this.#zone.rawValue?.widget.chatWidget.setModel(undefined); + this.#editor.focus(); + this.#ctxInlineChatVisible.reset(); } /** @@ -620,6 +740,7 @@ export class InlineChatController implements IEditorContribution { async rejectSession() { const session = this.#currentSession.get(); if (!session) { + this.#cancelPendingSessionAndHide(); return; } await this.#chatService.cancelCurrentRequestForSession(session.chatModel.sessionResource, 'inlineChatReject'); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditReviewSession.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditReviewSession.ts new file mode 100644 index 00000000000000..6bc327089bf590 --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditReviewSession.ts @@ -0,0 +1,341 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationError } from '../../../../base/common/errors.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { IMarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { IObservable, IReader, ITransaction, observableValue } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { localize } from '../../../../nls.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js'; +import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; +import { IChatExternalEdit } from '../../chat/common/chatService/chatService.js'; +import { ChatEditKind, IChatEditReviewSession, IModifiedEntryTelemetryInfo, IModifiedFileEntry } from '../../chat/common/editing/chatEditingService.js'; +import { IChatResponseModel } from '../../chat/common/model/chatModel.js'; +import { ChatEditingModifiedDocumentEntry } from '../../chat/browser/chatEditing/chatEditingModifiedDocumentEntry.js'; + +/** + * Provides inline-chat review UI for changes an agent host writes directly to disk. + */ +export class InlineChatEditReviewSession extends Disposable implements IChatEditReviewSession { + + readonly isGlobalEditingSession = false; + + private readonly _entriesObs = observableValue(this, []); + readonly entries: IObservable = this._entriesObs; + + private readonly _entries = new ResourceMap(); + private readonly _initialContents = new ResourceMap(); + private readonly _readonlyLocks = new ResourceMap(); + private readonly _externalEditListener = this._register(new MutableDisposable()); + private readonly _externalEditEntriesInFlight = new ResourceMap>(); + private readonly _onDidDispose = this._register(new Emitter()); + readonly onDidDispose = this._onDidDispose.event; + + private _requestId: string | undefined; + + constructor( + readonly chatSessionResource: URI, + private readonly _targetUri: URI, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ITextModelService private readonly _textModelService: ITextModelService, + @ITextFileService private readonly _textFileService: ITextFileService, + @IFilesConfigurationService private readonly _filesConfigurationService: IFilesConfigurationService, + @ILogService private readonly _logService: ILogService, + @IFileService private readonly _fileService: IFileService, + ) { + super(); + } + + /** + * Saves the target document, creates its review entry, and locks it read-only for the turn. + */ + async beginTurn(response: IChatResponseModel): Promise { + this._requestId = response.requestId; + this._externalEditListener.clear(); + + try { + if (this._textFileService.isDirty(this._targetUri)) { + // A cancelled save leaves the buffer dirty. Proceeding would let the agent + // write to disk and `endTurn`'s revert would then discard the user's unsaved + // changes, so treat it as a cancelled turn instead. + const saved = await this._textFileService.save(this._targetUri); + if (this._store.isDisposed) { + return; + } + if (!saved) { + throw new CancellationError(); + } + } + + this._readonlyLocks.set(this._targetUri, true); + await this._filesConfigurationService.updateReadonly(this._targetUri, this._getReadonlyMessage()); + if (this._store.isDisposed) { + return; + } + + let initialContent: string; + if (!this._entries.has(this._targetUri)) { + initialContent = await this._readCurrentContent(this._targetUri); + if (this._store.isDisposed) { + return; + } + this._initialContents.set(this._targetUri, initialContent); + } else { + initialContent = this._initialContents.get(this._targetUri) ?? ''; + } + + const entry = await this._getOrCreateEntry(this._targetUri, this._getTelemetryInfo(response), initialContent); + if (this._store.isDisposed || !entry) { + return; + } + // Keep carried-over entries in external-edit mode before agent disk writes arrive. + for (const tracked of this._entries.values()) { + tracked.startExternalEdit(); + } + this._externalEditListener.value = response.onDidChange(() => { + void this._processExternalEdits(response); + }); + void this._processExternalEdits(response); + } catch (error) { + this._logService.error(`Failed to prepare inline chat review for ${this._targetUri}`, error); + await this._resetReadonlyLocks(); + throw error; + } + } + + /** + * Reverts models from disk, finalizes external edits, and unlocks the target. + */ + async endTurn(response: IChatResponseModel): Promise { + this._externalEditListener.clear(); + try { + await this._processExternalEdits(response); + if (this._store.isDisposed) { + return; + } + } finally { + if (!this._store.isDisposed) { + for (const entry of this._entries.values()) { + try { + await entry.revertToDisk(); + } catch (error) { + this._logService.error(`Failed to reload inline chat review entry from disk for ${entry.modifiedURI}`, error); + } + } + + for (const entry of this._entries.values()) { + entry.stopExternalEdit(); + } + } + await this._resetReadonlyLocks(); + } + } + + getEntry(uri: URI): IModifiedFileEntry | undefined { + return this._entries.get(uri); + } + + readEntry(uri: URI, reader: IReader): IModifiedFileEntry | undefined { + this._entriesObs.read(reader); + return this._entries.get(uri); + } + + async accept(...uris: URI[]): Promise { + await Promise.all(this._getEntries(uris).map(entry => entry.accept())); + } + + async reject(...uris: URI[]): Promise { + await Promise.all(this._getEntries(uris).map(entry => entry.reject())); + } + + override dispose(): void { + this._externalEditListener.clear(); + this._onDidDispose.fire(); + for (const entry of this._entries.values()) { + entry.stopExternalEdit(); + } + void this._resetReadonlyLocks(); + super.dispose(); + } + + private _getEntries(uris: readonly URI[]): readonly ChatEditingModifiedDocumentEntry[] { + const entries = [...this._entries.values()]; + return uris.length === 0 ? entries : entries.filter(entry => uris.some(uri => isEqual(entry.modifiedURI, uri))); + } + + private async _processExternalEdits(response: IChatResponseModel): Promise { + for (const part of response.response.value) { + if (part.kind !== 'externalEdit') { + continue; + } + + try { + await this._getOrCreateExternalEditEntry(part, this._getTelemetryInfo(response)); + if (this._store.isDisposed) { + return; + } + } catch (error) { + this._logService.error(`Failed to create inline chat review entry for ${part.uri}`, error); + } + } + } + + private async _getOrCreateExternalEditEntry(edit: IChatExternalEdit, telemetryInfo: IModifiedEntryTelemetryInfo): Promise { + if (edit.editKind === 'delete' || edit.editKind === 'rename' || isEqual(edit.uri, this._targetUri)) { + return; + } + + if (this._entries.has(edit.uri)) { + const entry = await this._getOrCreateEntry(edit.uri, telemetryInfo, this._initialContents.get(edit.uri) ?? ''); + if (this._store.isDisposed || !entry) { + return; + } + entry.startExternalEdit(); + return; + } + + const inFlight = this._externalEditEntriesInFlight.get(edit.uri); + if (inFlight) { + await inFlight; + if (this._store.isDisposed) { + return; + } + return; + } + + const createEntry = this._createExternalEditEntry(edit, telemetryInfo); + this._externalEditEntriesInFlight.set(edit.uri, createEntry); + try { + await createEntry; + if (this._store.isDisposed) { + return; + } + } finally { + if (this._externalEditEntriesInFlight.get(edit.uri) === createEntry) { + this._externalEditEntriesInFlight.delete(edit.uri); + } + } + } + + private async _createExternalEditEntry(edit: IChatExternalEdit, telemetryInfo: IModifiedEntryTelemetryInfo): Promise { + let initialContent = this._initialContents.get(edit.uri); + if (initialContent === undefined) { + initialContent = await this._readBeforeContent(edit); + if (this._store.isDisposed) { + return; + } + this._initialContents.set(edit.uri, initialContent); + } + + // A created file must be tracked as such: rejecting it deletes the file, whereas a + // `Modified` entry would only restore empty content and leave the file behind. + const editKind = edit.editKind === 'create' ? ChatEditKind.Created : ChatEditKind.Modified; + await this._getOrCreateEntry(edit.uri, telemetryInfo, initialContent, true, editKind); + if (this._store.isDisposed) { + return; + } + } + + private async _readCurrentContent(resource: URI): Promise { + try { + const ref = await this._textModelService.createModelReference(resource); + try { + return ref.object.textEditorModel.getValue(); + } finally { + ref.dispose(); + } + } catch (error) { + this._logService.warn(`Failed to read model content for ${resource}; reading from disk instead.`, error); + return (await this._fileService.readFile(resource)).value.toString(); + } + } + + private async _readBeforeContent(edit: IChatExternalEdit): Promise { + if (!edit.beforeContentUri) { + return ''; + } + + try { + return (await this._fileService.readFile(edit.beforeContentUri)).value.toString(); + } catch (error) { + this._logService.warn(`Failed to read pre-edit content for ${edit.uri}.`, error); + return ''; + } + } + + private async _getOrCreateEntry(resource: URI, telemetryInfo: IModifiedEntryTelemetryInfo, initialContent: string, startExternalEdit = false, editKind = ChatEditKind.Modified): Promise { + const existingEntry = this._entries.get(resource); + if (existingEntry) { + if (telemetryInfo.requestId !== existingEntry.telemetryInfo.requestId) { + existingEntry.updateTelemetryInfo(telemetryInfo); + } + if (startExternalEdit) { + existingEntry.startExternalEdit(); + } + return existingEntry; + } + + const ref = await this._textModelService.createModelReference(resource); + if (this._store.isDisposed) { + ref.dispose(); + return undefined; + } + + const entry = this._register(this._instantiationService.createInstance( + ChatEditingModifiedDocumentEntry, + ref, + { collapse: (_tx: ITransaction | undefined) => { } }, + telemetryInfo, + editKind, + initialContent, + )); + if (startExternalEdit) { + entry.startExternalEdit(); + } + this._entries.set(resource, entry); + this._entriesObs.set([...this._entries.values()], undefined); + return entry; + } + + private _getTelemetryInfo(response: IChatResponseModel): IModifiedEntryTelemetryInfo { + const requestId = this._requestId ?? response.requestId; + return new class implements IModifiedEntryTelemetryInfo { + get agentId() { return response.agent?.id; } + get modelId() { return response.request?.modelId; } + get modeId() { return response.request?.modeInfo?.telemetryModeId; } + get command() { return response.slashCommand?.name; } + get sessionResource() { return response.session.sessionResource; } + get requestId() { return requestId; } + get result() { return response.result; } + get applyCodeBlockSuggestionId() { return response.request?.modeInfo?.applyCodeBlockSuggestionId; } + get feature(): 'inlineChat' { return 'inlineChat'; } + }; + } + + private _getReadonlyMessage(): IMarkdownString { + return { value: localize('inlineChatReadonly', "Editor is read-only while Copilot is editing this file.") }; + } + + private async _resetReadonlyLocks(): Promise { + const resources = [...this._readonlyLocks.keys()]; + this._readonlyLocks.clear(); + + for (const resource of resources) { + try { + await this._filesConfigurationService.updateReadonly(resource, 'reset'); + } catch (error) { + this._logService.error(`Failed to release inline chat read-only lock for ${resource}`, error); + } + } + } +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionResolver.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionResolver.ts new file mode 100644 index 00000000000000..6e6fd550e0b4ae --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionResolver.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { isCancellationError, onUnexpectedError } from '../../../../base/common/errors.js'; +import { withChatSurfaceMeta } from '../../../../platform/agentHost/common/meta/agentChatSurfaceMeta.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IChatModelReference, IChatService } from '../../chat/common/chatService/chatService.js'; +import { ChatAgentLocation, ChatConfiguration } from '../../chat/common/constants.js'; +import { IChatSessionsService, ResolvedChatSessionsExtensionPoint, SessionType } from '../../chat/common/chatSessionsService.js'; + +export const IInlineChatSessionResolver = createDecorator('inlineChatSessionResolver'); + +/** Result of resolving the chat model used by the editor inline chat surface. */ +export interface IInlineChatSessionResolution { + readonly modelRef: IChatModelReference; + /** + * The chat session contribution the widget must lock to so requests carry + * `agentIdSilent` and reach the Agent Host agent instead of the default inline + * participant. `undefined` for a local fallback session, which must stay on the + * legacy extension-host agent. + */ + readonly lockToAgent: ResolvedChatSessionsExtensionPoint | undefined; +} + +/** Resolves the chat model reference used by the editor inline chat surface. */ +export interface IInlineChatSessionResolver { + readonly _serviceBrand: undefined; + resolve(token: CancellationToken, languageId: string | undefined): Promise; +} + +/** Builds the Agent Host metadata for an editor inline chat session. */ +export function getInlineChatSessionMeta(languageId: string | undefined): Record { + return withChatSurfaceMeta(undefined, { surface: 'editorInline', languageId })!; +} + +/** Applies editor inline chat-specific Agent Host and local-session fallback policy. */ +export class InlineChatSessionResolver implements IInlineChatSessionResolver { + declare readonly _serviceBrand: undefined; + + constructor( + @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, + @IChatService private readonly _chatService: IChatService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { } + + async resolve(token: CancellationToken, languageId: string | undefined): Promise { + if (token.isCancellationRequested) { + return undefined; + } + + const meta = getInlineChatSessionMeta(languageId); + let modelRef: IChatModelReference | undefined; + const agentHostEnabled = this._configurationService.getValue(ChatConfiguration.InlineChatAgentHostEnabled) === true; + const contribution = agentHostEnabled ? this._chatSessionsService.getChatSessionContribution(SessionType.AgentHostCopilot) : undefined; + if (contribution?.locations?.includes(ChatAgentLocation.EditorInline)) { + try { + const item = await this._chatSessionsService.createNewChatSessionItem(SessionType.AgentHostCopilot, { + prompt: '', + isEphemeral: true, + _meta: meta, + }, token); + modelRef = item && await this._chatService.acquireOrLoadSession(item.resource, ChatAgentLocation.EditorInline, token, 'InlineChatSessionResolver#resolve'); + } catch (error) { + if (isCancellationError(error) || token.isCancellationRequested) { + throw error; + } + onUnexpectedError(error); + } + } + + if (token.isCancellationRequested) { + modelRef?.dispose(); + return undefined; + } + + if (modelRef) { + return { modelRef, lockToAgent: contribution }; + } + + modelRef = this._chatService.startNewLocalSession(ChatAgentLocation.EditorInline, { canUseTools: false /* SEE https://github.com/microsoft/vscode/issues/279946 */ }); + if (token.isCancellationRequested) { + modelRef.dispose(); + return undefined; + } + return { modelRef, lockToAgent: undefined }; + } +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts index 1fb914877cf014..6bfb3a53843cb6 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -11,10 +12,11 @@ import { Position } from '../../../../editor/common/core/position.js'; import { Selection } from '../../../../editor/common/core/selection.js'; import { createDecorator, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IChatWidgetService } from '../../chat/browser/chat.js'; -import { IChatEditingSession } from '../../chat/common/editing/chatEditingService.js'; +import { IChatEditReviewSession } from '../../chat/common/editing/chatEditingService.js'; import { IChatModel, IChatModelInputState, IChatRequestModel } from '../../chat/common/model/chatModel.js'; import { IChatService } from '../../chat/common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../../chat/common/constants.js'; +import { ResolvedChatSessionsExtensionPoint } from '../../chat/common/chatSessionsService.js'; export const IInlineChatSessionService = createDecorator('IInlineChatSessionService'); @@ -26,7 +28,8 @@ export interface IInlineChatSession { readonly initialSelection: Selection; readonly uri: URI; readonly chatModel: IChatModel; - readonly editingSession: IChatEditingSession; + readonly editingSession: IChatEditReviewSession; + readonly lockToAgent: ResolvedChatSessionsExtensionPoint | undefined; readonly terminationState: IObservable; setTerminationState(state: InlineChatSessionTerminationState | undefined): void; dispose(): void; @@ -38,7 +41,7 @@ export interface IInlineChatSessionService { readonly onWillStartSession: Event; readonly onDidChangeSessions: Event; - createSession(editor: ICodeEditor): IInlineChatSession; + createSession(editor: ICodeEditor, isNotebook: boolean, token: CancellationToken): Promise; getSessionByTextModel(uri: URI): IInlineChatSession | undefined; getSessionBySessionUri(uri: URI): IInlineChatSession | undefined; } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts index dfded373c0c61d..02868a990a73d3 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts @@ -2,9 +2,12 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationError, isCancellationError, onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, dispose, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; +import { Schemas } from '../../../../base/common/network.js'; import { autorun, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -12,16 +15,19 @@ import { IActiveCodeEditor, isCodeEditor, isCompositeEditor, isDiffEditor } from import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IChatAgentService } from '../../chat/common/participants/chatAgents.js'; -import { ModifiedFileEntryState } from '../../chat/common/editing/chatEditingService.js'; +import { IChatEditReviewSession, IChatEditingService, ModifiedFileEntryState } from '../../chat/common/editing/chatEditingService.js'; import { IChatService } from '../../chat/common/chatService/chatService.js'; import { ChatAgentLocation } from '../../chat/common/constants.js'; import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../chat/common/tools/languageModelToolsService.js'; import { CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_HAS_NOTEBOOK_AGENT, CTX_INLINE_CHAT_POSSIBLE, InlineChatConfigKeys } from '../common/inlineChat.js'; +import { InlineChatEditReviewSession } from './inlineChatEditReviewSession.js'; import { IInlineChatSession, IInlineChatSessionService, InlineChatSessionTerminationState } from './inlineChatSessionService.js'; +import { IInlineChatSessionResolver } from './inlineChatSessionResolver.js'; export class InlineChatError extends Error { static readonly code = 'InlineChatError'; @@ -45,12 +51,21 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { readonly onDidChangeSessions: Event = this.#onDidChangeSessions.event; readonly #chatService: IChatService; + readonly #inlineChatSessionResolver: IInlineChatSessionResolver; + readonly #instantiationService: IInstantiationService; + readonly #chatEditingService: IChatEditingService; constructor( @IChatService chatService: IChatService, @IChatAgentService chatAgentService: IChatAgentService, + @IInlineChatSessionResolver inlineChatSessionResolver: IInlineChatSessionResolver, + @IInstantiationService instantiationService: IInstantiationService, + @IChatEditingService chatEditingService: IChatEditingService, ) { this.#chatService = chatService; + this.#inlineChatSessionResolver = inlineChatSessionResolver; + this.#instantiationService = instantiationService; + this.#chatEditingService = chatEditingService; // Listen for agent changes and dispose all sessions when there is no agent const agentObs = observableFromEvent(this, chatAgentService.onDidChangeAgents, () => chatAgentService.getDefaultAgent(ChatAgentLocation.EditorInline)); this.#store.add(autorun(r => { @@ -68,8 +83,9 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { } - createSession(editor: IActiveCodeEditor): IInlineChatSession { - const uri = editor.getModel().uri; + async createSession(editor: IActiveCodeEditor, isNotebook: boolean, token: CancellationToken): Promise { + const model = editor.getModel(); + const uri = model.uri; if (this.#sessions.has(uri)) { throw new Error('Session already exists'); @@ -77,23 +93,52 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { this.#onWillStartSession.fire(editor); - const chatModelRef = this.#chatService.startNewLocalSession(ChatAgentLocation.EditorInline, { canUseTools: false /* SEE https://github.com/microsoft/vscode/issues/279946 */ }); + const isAgentHostEligible = uri.scheme === Schemas.file && !isNotebook; + const resolution = isAgentHostEligible + ? await this.#inlineChatSessionResolver.resolve(token, model.getLanguageId()) + : undefined; + if (token.isCancellationRequested || (isAgentHostEligible && !resolution)) { + resolution?.modelRef.dispose(); + throw new CancellationError(); + } + // Re-check after the await: a second controller for the same file (e.g. a split + // editor) can pass the check above and resolve concurrently, and the loser would + // otherwise overwrite the map entry the winner owns. + if (this.#sessions.has(uri)) { + resolution?.modelRef.dispose(); + throw new Error('Session already exists'); + } + const chatModelRef = resolution?.modelRef ?? this.#chatService.startNewLocalSession(ChatAgentLocation.EditorInline, { canUseTools: false /* SEE https://github.com/microsoft/vscode/issues/279946 */ }); const chatModel = chatModelRef.object; - chatModel.startEditingSession(false); + const lockToAgent = resolution?.lockToAgent; + let reviewSession: InlineChatEditReviewSession | undefined; + let editingSession: IChatEditReviewSession; + if (lockToAgent) { + reviewSession = this.#instantiationService.createInstance(InlineChatEditReviewSession, chatModel.sessionResource, uri); + editingSession = reviewSession; + } else { + chatModel.startEditingSession(false); + editingSession = chatModel.editingSession!; + } const terminationState = observableValue(this, undefined); const store = new DisposableStore(); store.add(toDisposable(() => { void this.#chatService.cancelCurrentRequestForSession(chatModel.sessionResource, 'inlineChatSession'); - chatModel.editingSession?.reject(); + void editingSession.reject(); this.#sessions.delete(uri); this.#onDidChangeSessions.fire(this); })); store.add(chatModelRef); + if (reviewSession) { + store.add(this.#chatEditingService.registerEditReviewSession(reviewSession)); + store.add(reviewSession); + this.#installEditReviewObserver(chatModel, reviewSession, store); + } store.add(autorun(r => { - const entries = chatModel.editingSession?.entries.read(r); + const entries = editingSession.entries.read(r); if (!entries?.length) { return; } @@ -133,7 +178,8 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { initialPosition: editor.getSelection().getStartPosition().delta(-1), /* one line above selection start */ initialSelection: editor.getSelection(), chatModel, - editingSession: chatModel.editingSession!, + editingSession, + lockToAgent, terminationState, setTerminationState: state => { terminationState.set(state, undefined); @@ -146,6 +192,75 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { return result; } + #installEditReviewObserver(chatModel: IInlineChatSession['chatModel'], reviewSession: InlineChatEditReviewSession, store: DisposableStore): void { + let turnQueue = Promise.resolve(); + let isDisposed = false; + store.add(toDisposable(() => isDisposed = true)); + + store.add(chatModel.onDidChange(async e => { + if (e.kind !== 'addRequest' || !e.request.response) { + return; + } + + const response = e.request.response; + const previousTurn = turnQueue; + let completeTurn: (() => void) | undefined; + turnQueue = new Promise(resolve => completeTurn = resolve); + + await previousTurn; + if (isDisposed) { + completeTurn?.(); + return; + } + + let beganTurn = false; + try { + await reviewSession.beginTurn(response); + beganTurn = true; + if (isDisposed) { + return; + } + + if (!response.isComplete) { + const responseStore = new DisposableStore(); + try { + await new Promise(resolve => { + responseStore.add(response.onDidChange(() => { + if (response.isComplete) { + resolve(); + } + })); + responseStore.add(reviewSession.onDidDispose(resolve)); + if (response.isComplete) { + resolve(); + } + }); + } finally { + responseStore.dispose(); + } + } + } catch (error) { + // Preparation failed, so the file is not locked and there is no review + // baseline. Cancel the request rather than letting the agent write unguarded. + if (!isDisposed) { + void this.#chatService.cancelCurrentRequestForSession(chatModel.sessionResource, 'inlineChatBeginTurnFailed'); + } + if (!isCancellationError(error)) { + onUnexpectedError(error); + } + } finally { + if (beganTurn && !isDisposed) { + try { + await reviewSession.endTurn(response); + } catch (error) { + onUnexpectedError(error); + } + } + completeTurn?.(); + } + })); + } + getSessionByTextModel(uri: URI): IInlineChatSession | undefined { let result = this.#sessions.get(uri); if (!result) { diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatEditReviewSession.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatEditReviewSession.test.ts new file mode 100644 index 00000000000000..0456b2deb370e6 --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatEditReviewSession.test.ts @@ -0,0 +1,454 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { isCancellationError } from '../../../../../base/common/errors.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IReference } from '../../../../../base/common/lifecycle.js'; +import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; +import { waitForState } 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 { ITextModel } from '../../../../../editor/common/model.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { IResolvedTextEditorModel, ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { IAutoSaveConfiguration, IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; +import { ITextFileEditorModelManager, ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { IFileContent, IFileService } from '../../../../../platform/files/common/files.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { INotebookService } from '../../../notebook/common/notebookService.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { IChatExternalEdit, IChatService, IChatUserActionEvent } from '../../../chat/common/chatService/chatService.js'; +import { ModifiedFileEntryState } from '../../../chat/common/editing/chatEditingService.js'; +import { ChatResponseModelChangeReason, IChatResponseModel } from '../../../chat/common/model/chatModel.js'; +import { ChatEditingModifiedDocumentEntry } from '../../../chat/browser/chatEditing/chatEditingModifiedDocumentEntry.js'; +import { InlineChatEditReviewSession } from '../../browser/inlineChatEditReviewSession.js'; +import { TestWorkerService } from './testWorkerService.js'; + +suite('InlineChatEditReviewSession', () => { + + const store = new DisposableStore(); + const chatSessionResource = URI.parse('chat-session:test'); + const targetUri = URI.parse('test:/target.ts'); + let session: InlineChatEditReviewSession; + let modelService: IModelService; + let readonlyUpdates: { resource: URI; value: true | false | 'toggle' | 'reset' | { value: string } }[]; + let seededContents: ResourceMap; + let beforeContents: ResourceMap; + let models: ResourceMap; + let dirtyResources: ResourceSet; + let cancelSave: boolean; + + interface ITestChatResponse extends IChatResponseModel { + addExternalEdit(edit: IChatExternalEdit): void; + } + + function getModel(resource: URI): ITextModel { + let model = models.get(resource) ?? modelService.getModel(resource); + if (!model) { + model = store.add(modelService.createModel(seededContents.get(resource) ?? '', null, resource, false)); + } + models.set(resource, model); + return model; + } + + function createResponse(parts: readonly IChatExternalEdit[] = [], requestId = 'request-1'): ITestChatResponse { + const responseParts = [...parts]; + const onDidChange = store.add(new Emitter()); + return new class extends mock() { + override readonly requestId = requestId; + override readonly response = { + value: responseParts, + getMarkdown: () => '', + getFinalResponse: () => '', + toString: () => '', + }; + override readonly agent = { id: 'agent' } as IChatResponseModel['agent']; + override readonly slashCommand = { name: 'inline' } as IChatResponseModel['slashCommand']; + override readonly request = { modelId: 'model', modeInfo: { telemetryModeId: 'edit' } } as IChatResponseModel['request']; + override readonly session = { sessionResource: chatSessionResource } as IChatResponseModel['session']; + override readonly result = undefined; + override readonly onDidChange = onDidChange.event; + override addExternalEdit(edit: IChatExternalEdit): void { + responseParts.push(edit); + onDidChange.fire({ reason: 'other' }); + } + }; + } + + async function beginAndEnd(targetContent: string, response = createResponse()): Promise { + await session.beginTurn(response); + getModel(targetUri).setValue(targetContent); + await session.endTurn(response); + } + + setup(() => { + readonlyUpdates = []; + seededContents = new ResourceMap(); + beforeContents = new ResourceMap(); + models = new ResourceMap(); + dirtyResources = new ResourceSet(); + cancelSave = false; + + const textModelService = new class extends mock() { + override async createModelReference(resource: URI): Promise> { + return { + dispose: () => { }, + object: { + textEditorModel: getModel(resource), + getLanguageId: () => 'typescript', + } as IResolvedTextEditorModel + }; + } + }(); + const textFileService = new class extends mock() { + override readonly files = new class extends mock() { + override get(_resource: URI) { + return undefined; + } + }(); + override isDirty(_resource: URI): boolean { + return dirtyResources.has(_resource); + } + override async save(resource: URI): Promise { + return cancelSave ? undefined : resource; + } + }(); + const filesConfigurationService = new class extends mock() { + override async updateReadonly(resource: URI | URI[], value: true | false | 'toggle' | 'reset' | { value: string }): Promise { + for (const uri of Array.isArray(resource) ? resource : [resource]) { + readonlyUpdates.push({ resource: uri, value }); + } + } + override getAutoSaveConfiguration(_resource: URI): IAutoSaveConfiguration { + return {}; + } + }(); + const fileService = new class extends mock() { + override readonly onDidFilesChange = Event.None; + override watch(_resource: URI) { + return Disposable.None; + } + override async readFile(resource: URI): Promise { + return { value: VSBuffer.fromString(beforeContents.get(resource) ?? '') } as IFileContent; + } + }(); + + const collection = new ServiceCollection(); + collection.set(ITextModelService, textModelService); + collection.set(ITextFileService, textFileService); + collection.set(IFilesConfigurationService, filesConfigurationService); + collection.set(IFileService, fileService); + collection.set(IEditorWorkerService, new SyncDescriptor(TestWorkerService)); + collection.set(IChatService, new class extends mock() { + override notifyUserAction(_event: IChatUserActionEvent): void { } + }()); + collection.set(INotebookService, new class extends mock() { + override hasSupportedNotebooks(_resource: URI): boolean { + return false; + } + }()); + + const insta = store.add(store.add(workbenchInstantiationService(undefined, store)).createChild(collection)); + modelService = insta.get(IModelService); + store.add(insta.get(IEditorWorkerService) as TestWorkerService); + session = store.add(insta.createInstance(InlineChatEditReviewSession, chatSessionResource, targetUri)); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('creates a review entry with the pre-turn content before the turn ends', async () => { + seededContents.set(targetUri, 'const value = 1;\n'); + + await session.beginTurn(createResponse()); + + const entry = session.getEntry(targetUri) as ChatEditingModifiedDocumentEntry; + assert.deepStrictEqual({ + initialContent: entry.initialContent, + state: entry.state.get(), + uri: entry.modifiedURI.toString(), + }, { + initialContent: 'const value = 1;\n', + state: ModifiedFileEntryState.Modified, + uri: targetUri.toString(), + }); + await session.endTurn(createResponse()); + }); + + test('updates the review diff while the agent edits', async () => { + seededContents.set(targetUri, 'const value = 1;\n'); + const response = createResponse(); + + await session.beginTurn(response); + const entry = session.getEntry(targetUri) as ChatEditingModifiedDocumentEntry; + getModel(targetUri).setValue('const value = 2;\n'); + + const diff = await waitForState(entry.diffInfo.map(value => value.changes.length > 0 ? value : undefined)); + assert.strictEqual(diff.changes.length, 1); + + await session.endTurn(response); + }); + + test('locks the target with a markdown message and resets it after the turn', async () => { + seededContents.set(targetUri, 'before'); + + await beginAndEnd('after'); + + assert.deepStrictEqual(readonlyUpdates.map(update => ({ + resource: update.resource.toString(), + value: update.value, + })), [ + { resource: targetUri.toString(), value: { value: 'Editor is read-only while Copilot is editing this file.' } }, + { resource: targetUri.toString(), value: 'reset' }, + ]); + }); + + test('releases the read-only lock when disposed during a turn', async () => { + seededContents.set(targetUri, 'before'); + + await session.beginTurn(createResponse()); + session.dispose(); + await Promise.resolve(); + + assert.deepStrictEqual(readonlyUpdates.map(update => update.value), [ + { value: 'Editor is read-only while Copilot is editing this file.' }, + 'reset', + ]); + }); + + test('creates an entry for an off-target external edit using its before content', async () => { + const externalUri = URI.parse('test:/external.ts'); + const beforeContentUri = URI.parse('test:/before-external.ts'); + seededContents.set(targetUri, 'target before'); + seededContents.set(externalUri, 'external after'); + beforeContents.set(beforeContentUri, 'external before'); + const response = createResponse([{ + kind: 'externalEdit', + uri: externalUri, + editKind: 'edit', + beforeContentUri, + }]); + + await beginAndEnd('target after', response); + + const entries = session.entries.get() as ChatEditingModifiedDocumentEntry[]; + assert.deepStrictEqual(entries.map(entry => ({ + uri: entry.modifiedURI.toString(), + initialContent: entry.initialContent, + })), [ + { uri: targetUri.toString(), initialContent: 'target before' }, + { uri: externalUri.toString(), initialContent: 'external before' }, + ]); + }); + + test('creates an off-target entry with a populated diff before the turn ends', async () => { + const externalUri = URI.parse('test:/realtime-external.ts'); + const beforeContentUri = URI.parse('test:/before-realtime-external.ts'); + seededContents.set(targetUri, 'target before'); + seededContents.set(externalUri, 'external after'); + beforeContents.set(beforeContentUri, 'external before'); + const response = createResponse(); + + await session.beginTurn(response); + response.addExternalEdit({ + kind: 'externalEdit', + uri: externalUri, + editKind: 'edit', + beforeContentUri, + }); + + const entry = await waitForState(session.entries.map(entries => entries.find(candidate => candidate.modifiedURI.toString() === externalUri.toString()))) as ChatEditingModifiedDocumentEntry; + const diff = await waitForState(entry.diffInfo.map(value => value.changes.length > 0 ? value : undefined)); + assert.deepStrictEqual({ + initialContent: entry.initialContent, + diffChanges: diff.changes.length, + }, { + initialContent: 'external before', + diffChanges: 1, + }); + + await session.endTurn(response); + }); + + test('skips deleted external edits', async () => { + const deletedUri = URI.parse('test:/deleted.ts'); + seededContents.set(targetUri, 'before'); + const response = createResponse([{ + kind: 'externalEdit', + uri: deletedUri, + editKind: 'delete', + }]); + + await beginAndEnd('after', response); + + assert.deepStrictEqual(session.entries.get().map(entry => entry.modifiedURI.toString()), [targetUri.toString()]); + }); + + test('does not lock or edit when the pre-turn save is cancelled', async () => { + // A cancelled save leaves the buffer dirty. Proceeding would let the agent write to + // disk and the end-of-turn revert would discard the user's unsaved work. + seededContents.set(targetUri, 'before'); + dirtyResources.add(targetUri); + cancelSave = true; + + const response = createResponse(); + await assert.rejects(() => session.beginTurn(response), err => isCancellationError(err)); + + assert.deepStrictEqual({ + entryCount: session.entries.get().length, + locksLeftHeld: readonlyUpdates.filter(update => update.value !== 'reset').length, + }, { entryCount: 0, locksLeftHeld: 0 }); + }); + + test('tracks a created off-target file as created so rejecting deletes it', async () => { + const createdUri = URI.parse('test:/created.ts'); + seededContents.set(targetUri, 'before'); + seededContents.set(createdUri, 'generated\n'); + const response = createResponse([{ + kind: 'externalEdit', + uri: createdUri, + editKind: 'create', + }]); + + await session.beginTurn(response); + const entry = await waitForState(session.entries.map(entries => entries.find(candidate => candidate.modifiedURI.toString() === createdUri.toString()))); + await session.endTurn(response); + + const created = entry as ChatEditingModifiedDocumentEntry; + assert.strictEqual(created.createdInRequestId, created.telemetryInfo.requestId); + }); + + test('skips renamed external edits', async () => { + const renamedUri = URI.parse('test:/renamed.ts'); + seededContents.set(targetUri, 'before'); + const response = createResponse([{ + kind: 'externalEdit', + uri: renamedUri, + editKind: 'rename', + }]); + + await beginAndEnd('after', response); + + assert.deepStrictEqual(session.entries.get().map(entry => entry.modifiedURI.toString()), [targetUri.toString()]); + }); + + test('accepts all entries', async () => { + const externalUri = URI.parse('test:/accepted-external.ts'); + seededContents.set(targetUri, 'before'); + seededContents.set(externalUri, 'external'); + await beginAndEnd('accepted', createResponse([{ + kind: 'externalEdit', + uri: externalUri, + editKind: 'edit', + }])); + + await session.accept(); + + assert.deepStrictEqual(session.entries.get().map(entry => entry.state.get()), [ + ModifiedFileEntryState.Accepted, + ModifiedFileEntryState.Accepted, + ]); + }); + + test('rejects all entries', async () => { + seededContents.set(targetUri, 'before'); + await beginAndEnd('rejected'); + + await session.reject(); + + assert.deepStrictEqual(session.entries.get().map(entry => entry.state.get()), [ModifiedFileEntryState.Rejected]); + }); + + test('does not create duplicate entries for the target across turns', async () => { + seededContents.set(targetUri, 'before'); + + await beginAndEnd('first'); + await beginAndEnd('second', createResponse()); + + assert.deepStrictEqual(session.entries.get().map(entry => entry.modifiedURI.toString()), [targetUri.toString()]); + }); + + test('keeps the initial content and cumulative diff across turns', async () => { + const initialContent = 'one\ntwo\nthree\nfour\nfive\n'; + seededContents.set(targetUri, initialContent); + const firstResponse = createResponse([], 'request-1'); + const secondResponse = createResponse([], 'request-2'); + + await session.beginTurn(firstResponse); + getModel(targetUri).setValue('ONE\ntwo\nthree\nfour\nfive\n'); + await session.endTurn(firstResponse); + + await session.beginTurn(secondResponse); + getModel(targetUri).setValue('ONE\ntwo\nthree\nfour\nFIVE\n'); + await session.endTurn(secondResponse); + + const entry = session.getEntry(targetUri) as ChatEditingModifiedDocumentEntry; + const diff = await waitForState(entry.diffInfo.map(value => value.changes.length === 2 ? value : undefined)); + assert.deepStrictEqual({ + entryCount: session.entries.get().length, + initialContent: entry.initialContent, + modifiedContent: entry.modifiedModel.getValue(), + diffChanges: diff.changes.length, + }, { + entryCount: 1, + initialContent, + modifiedContent: 'ONE\ntwo\nthree\nfour\nFIVE\n', + diffChanges: 2, + }); + }); + + test('keeps an off-target file baseline across turns', async () => { + const externalUri = URI.parse('test:/cumulative-external.ts'); + const beforeContentUri = URI.parse('test:/before-cumulative-external.ts'); + const initialContent = 'one\ntwo\nthree\nfour\nfive\n'; + seededContents.set(targetUri, 'target'); + seededContents.set(externalUri, initialContent); + beforeContents.set(beforeContentUri, initialContent); + const firstResponse = createResponse([{ + kind: 'externalEdit', + uri: externalUri, + editKind: 'edit', + beforeContentUri, + }], 'request-1'); + const secondResponse = createResponse([{ + kind: 'externalEdit', + uri: externalUri, + editKind: 'edit', + beforeContentUri, + }], 'request-2'); + + await session.beginTurn(firstResponse); + const entry = await waitForState(session.entries.map(entries => entries.find(candidate => candidate.modifiedURI.toString() === externalUri.toString()))) as ChatEditingModifiedDocumentEntry; + getModel(externalUri).setValue('ONE\ntwo\nthree\nfour\nfive\n'); + await session.endTurn(firstResponse); + const firstDiff = await entry.getDiffInfo(); + assert.strictEqual(firstDiff.changes.length, 1); + + await session.beginTurn(secondResponse); + getModel(externalUri).setValue('ONE\ntwo\nthree\nfour\nFIVE\n'); + await session.endTurn(secondResponse); + + const diff = await entry.getDiffInfo(); + assert.deepStrictEqual({ + entryCount: session.entries.get().filter(candidate => candidate.modifiedURI.toString() === externalUri.toString()).length, + initialContent: entry.initialContent, + modifiedContent: entry.modifiedModel.getValue(), + firstDiffChanges: firstDiff.changes.length, + diffChanges: diff.changes.length, + }, { + entryCount: 1, + initialContent, + modifiedContent: 'ONE\ntwo\nthree\nfour\nFIVE\n', + firstDiffChanges: 1, + diffChanges: 2, + }); + }); +}); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionResolver.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionResolver.test.ts new file mode 100644 index 00000000000000..4ee1b4c7ede7ee --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionResolver.test.ts @@ -0,0 +1,318 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationError, errorHandler, isCancellationError, setUnexpectedErrorHandler } from '../../../../../base/common/errors.js'; +import { DisposableStore } 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 { readChatSurfaceMeta } from '../../../../../platform/agentHost/common/meta/agentChatSurfaceMeta.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IChatModelReference, IChatService, IChatSessionStartOptions } from '../../../chat/common/chatService/chatService.js'; +import { ChatAgentLocation } from '../../../chat/common/constants.js'; +import { IChatNewSessionRequest, IChatSessionItem, IChatSessionsService, ResolvedChatSessionsExtensionPoint, SessionType } from '../../../chat/common/chatSessionsService.js'; +import { InlineChatSessionResolver } from '../../browser/inlineChatSessionResolver.js'; + +const editorInlineContribution: ResolvedChatSessionsExtensionPoint = { + type: SessionType.AgentHostCopilot, + name: 'Agent Host Copilot', + displayName: 'Agent Host Copilot', + description: 'Test contribution', + icon: undefined, + locations: [ChatAgentLocation.EditorInline], +}; + +const agentHostItem: IChatSessionItem = { + resource: URI.from({ scheme: SessionType.AgentHostCopilot, path: '/inline-chat-session' }), + label: 'Inline chat session', + timing: { created: 0, lastRequestStarted: 0, lastRequestEnded: 0 }, +}; + +class TestModelReference extends mock() { + override readonly object = {} as IChatModelReference['object']; + disposed = false; + + override dispose(): void { + this.disposed = true; + } +} + +class TestConfigurationService extends mock() { + agentHostEnabled = true; + + override getValue(): T { + return this.agentHostEnabled as T; + } +} + +class TestChatSessionsService extends mock() { + contribution: ResolvedChatSessionsExtensionPoint | undefined = editorInlineContribution; + item: IChatSessionItem | undefined = agentHostItem; + error: Error | undefined; + readonly contributionLookups: string[] = []; + readonly creationCalls: Array<{ sessionType: string; request: IChatNewSessionRequest }> = []; + + override getChatSessionContribution(sessionType: string): ResolvedChatSessionsExtensionPoint | undefined { + this.contributionLookups.push(sessionType); + return this.contribution; + } + + override async createNewChatSessionItem(sessionType: string, request: IChatNewSessionRequest): Promise { + this.creationCalls.push({ sessionType, request }); + if (this.error) { + throw this.error; + } + return this.item; + } +} + +class TestChatService extends mock() { + agentHostReference: IChatModelReference | undefined; + agentHostResult: Promise | undefined; + agentHostError: Error | undefined; + readonly localReference = new TestModelReference(); + readonly acquisitionStarted = new DeferredPromise(); + readonly acquisitionCalls: Array<{ location: ChatAgentLocation; debugOwner: string | undefined }> = []; + readonly localSessionCalls: Array<{ location: ChatAgentLocation; options: IChatSessionStartOptions | undefined }> = []; + + override async acquireOrLoadSession(_sessionResource: URI, location: ChatAgentLocation, _token: CancellationToken, debugOwner?: string): Promise { + this.acquisitionCalls.push({ location, debugOwner }); + this.acquisitionStarted.complete(); + if (this.agentHostError) { + throw this.agentHostError; + } + return this.agentHostResult ?? this.agentHostReference; + } + + override startNewLocalSession(location: ChatAgentLocation, options?: IChatSessionStartOptions): IChatModelReference { + this.localSessionCalls.push({ location, options }); + return this.localReference; + } +} + +suite('InlineChatSessionResolver', () => { + const store = new DisposableStore(); + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + let chatSessionsService: TestChatSessionsService; + let chatService: TestChatService; + let resolver: InlineChatSessionResolver; + + setup(() => { + instantiationService = store.add(new TestInstantiationService()); + configurationService = new TestConfigurationService(); + chatSessionsService = new TestChatSessionsService(); + chatService = new TestChatService(); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IChatSessionsService, chatSessionsService); + instantiationService.stub(IChatService, chatService); + resolver = instantiationService.createInstance(InlineChatSessionResolver); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses a local session without consulting Agent Host when disabled', async () => { + configurationService.agentHostEnabled = false; + + const result = await resolver.resolve(CancellationToken.None, 'typescript'); + + assert.deepStrictEqual({ + usesLocalReference: result?.modelRef === chatService.localReference, + lockToAgent: result?.lockToAgent, + contributionLookups: chatSessionsService.contributionLookups, + creationCalls: chatSessionsService.creationCalls, + localSessionCalls: chatService.localSessionCalls, + }, { + usesLocalReference: true, + lockToAgent: undefined, + contributionLookups: [], + creationCalls: [], + localSessionCalls: [{ location: ChatAgentLocation.EditorInline, options: { canUseTools: false } }], + }); + }); + + test('uses an ephemeral Agent Host session for editor inline chat', async () => { + const agentHostReference = new TestModelReference(); + chatService.agentHostReference = agentHostReference; + + const result = await resolver.resolve(CancellationToken.None, 'typescript'); + const creation = chatSessionsService.creationCalls[0]; + + assert.deepStrictEqual({ + usesAgentHostReference: result?.modelRef === agentHostReference, + locksToExactContribution: result?.lockToAgent === editorInlineContribution, + creation: creation && { + sessionType: creation.sessionType, + request: creation.request, + surfaceMeta: readChatSurfaceMeta(creation.request), + }, + acquisitionCalls: chatService.acquisitionCalls, + localSessionCalls: chatService.localSessionCalls, + }, { + usesAgentHostReference: true, + locksToExactContribution: true, + creation: { + sessionType: SessionType.AgentHostCopilot, + request: { + prompt: '', + isEphemeral: true, + _meta: { + 'vscode.chat.surface': { surface: 'editorInline', languageId: 'typescript' }, + }, + }, + surfaceMeta: { surface: 'editorInline', languageId: 'typescript' }, + }, + acquisitionCalls: [{ location: ChatAgentLocation.EditorInline, debugOwner: 'InlineChatSessionResolver#resolve' }], + localSessionCalls: [], + }); + }); + + test('falls back to a local session when the contribution is missing', async () => { + chatSessionsService.contribution = undefined; + + const result = await resolver.resolve(CancellationToken.None, 'typescript'); + + assert.deepStrictEqual({ + usesLocalReference: result?.modelRef === chatService.localReference, + lockToAgent: result?.lockToAgent, + creationCalls: chatSessionsService.creationCalls, + localSessionCalls: chatService.localSessionCalls, + }, { + usesLocalReference: true, + lockToAgent: undefined, + creationCalls: [], + localSessionCalls: [{ location: ChatAgentLocation.EditorInline, options: { canUseTools: false } }], + }); + }); + + test('falls back to a local session when the contribution does not support editor inline chat', async () => { + chatSessionsService.contribution = { ...editorInlineContribution, locations: [ChatAgentLocation.Chat, ChatAgentLocation.Terminal] }; + + const result = await resolver.resolve(CancellationToken.None, 'typescript'); + + assert.deepStrictEqual({ + usesLocalReference: result?.modelRef === chatService.localReference, + lockToAgent: result?.lockToAgent, + creationCalls: chatSessionsService.creationCalls, + localSessionCalls: chatService.localSessionCalls, + }, { + usesLocalReference: true, + lockToAgent: undefined, + creationCalls: [], + localSessionCalls: [{ location: ChatAgentLocation.EditorInline, options: { canUseTools: false } }], + }); + }); + + test('falls back to a local session when Agent Host does not create an item', async () => { + chatSessionsService.item = undefined; + + const result = await resolver.resolve(CancellationToken.None, 'typescript'); + + assert.deepStrictEqual({ + usesLocalReference: result?.modelRef === chatService.localReference, + lockToAgent: result?.lockToAgent, + creationCalls: chatSessionsService.creationCalls.length, + acquisitionCalls: chatService.acquisitionCalls, + localSessionCalls: chatService.localSessionCalls, + }, { + usesLocalReference: true, + lockToAgent: undefined, + creationCalls: 1, + acquisitionCalls: [], + localSessionCalls: [{ location: ChatAgentLocation.EditorInline, options: { canUseTools: false } }], + }); + }); + + test('swallows a non-cancellation Agent Host error and falls back to a local session', async () => { + const originalErrorHandler = errorHandler.getUnexpectedErrorHandler(); + const reportedErrors: string[] = []; + chatSessionsService.error = new Error('Agent Host unavailable'); + setUnexpectedErrorHandler(error => reportedErrors.push(error instanceof Error ? error.message : String(error))); + try { + const result = await resolver.resolve(CancellationToken.None, 'typescript'); + + assert.deepStrictEqual({ + usesLocalReference: result?.modelRef === chatService.localReference, + lockToAgent: result?.lockToAgent, + localSessionCalls: chatService.localSessionCalls, + reportedErrors, + }, { + usesLocalReference: true, + lockToAgent: undefined, + localSessionCalls: [{ location: ChatAgentLocation.EditorInline, options: { canUseTools: false } }], + reportedErrors: ['Agent Host unavailable'], + }); + } finally { + setUnexpectedErrorHandler(originalErrorHandler); + } + }); + + test('does not create a local session when Agent Host is cancelled', async () => { + chatSessionsService.error = new CancellationError(); + + await assert.rejects( + resolver.resolve(CancellationToken.None, 'typescript'), + isCancellationError, + ); + + assert.deepStrictEqual({ + creationCalls: chatSessionsService.creationCalls.length, + localSessionCalls: chatService.localSessionCalls, + }, { + creationCalls: 1, + localSessionCalls: [], + }); + }); + + test('does not create a local session when the token is already cancelled', async () => { + const cancellationSource = store.add(new CancellationTokenSource()); + cancellationSource.cancel(); + + const result = await resolver.resolve(cancellationSource.token, 'typescript'); + + assert.deepStrictEqual({ + result, + contributionLookups: chatSessionsService.contributionLookups, + creationCalls: chatSessionsService.creationCalls, + localSessionCalls: chatService.localSessionCalls, + }, { + result: undefined, + contributionLookups: [], + creationCalls: [], + localSessionCalls: [], + }); + }); + + test('disposes an Agent Host model reference acquired after cancellation', async () => { + const agentHostReference = new TestModelReference(); + const pendingAcquisition = new DeferredPromise(); + const cancellationSource = store.add(new CancellationTokenSource()); + chatService.agentHostResult = pendingAcquisition.p; + + const resolving = resolver.resolve(cancellationSource.token, 'typescript'); + await chatService.acquisitionStarted.p; + cancellationSource.cancel(); + pendingAcquisition.complete(agentHostReference); + const result = await resolving; + + assert.deepStrictEqual({ + result, + localSessionCalls: chatService.localSessionCalls, + disposed: agentHostReference.disposed, + }, { + result: undefined, + localSessionCalls: [], + disposed: true, + }); + }); +}); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionService.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionService.test.ts new file mode 100644 index 00000000000000..3247a345339c73 --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSessionService.test.ts @@ -0,0 +1,376 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../base/common/event.js'; +import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable, DisposableStore, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { observableValue, waitForState } 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 { IActiveCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { Selection } from '../../../../../editor/common/core/selection.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; +import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; +import { NullWorkbenchAssignmentService } from '../../../../services/assignment/test/common/nullAssignmentService.js'; +import { IWorkspaceEditingService } from '../../../../services/workspaces/common/workspaceEditing.js'; +import { nullExtensionDescription } from '../../../../services/extensions/common/extensions.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { ChatEditingService } from '../../../chat/browser/chatEditing/chatEditingServiceImpl.js'; +import { ChatEditingSession } from '../../../chat/browser/chatEditing/chatEditingSession.js'; +import { ChatSessionsService } from '../../../chat/browser/chatSessions/chatSessions.contribution.js'; +import { IChatService } from '../../../chat/common/chatService/chatService.js'; +import { ChatService } from '../../../chat/common/chatService/chatServiceImpl.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../chat/common/constants.js'; +import { IChatEditingService } from '../../../chat/common/editing/chatEditingService.js'; +import { ChatModel } from '../../../chat/common/model/chatModel.js'; +import { IChatAgentData, IChatAgentImplementation, IChatAgentService, ChatAgentService } from '../../../chat/common/participants/chatAgents.js'; +import { IChatSessionsService, ResolvedChatSessionsExtensionPoint, SessionType } from '../../../chat/common/chatSessionsService.js'; +import { IChatDebugService } from '../../../chat/common/chatDebugService.js'; +import { ChatDebugServiceImpl } from '../../../chat/common/chatDebugServiceImpl.js'; +import { IChatSlashCommandService } from '../../../chat/common/participants/chatSlashCommands.js'; +import { ChatTransferService, IChatTransferService } from '../../../chat/common/model/chatTransferService.js'; +import { IChatVariablesService } from '../../../chat/common/attachments/chatVariables.js'; +import { ILanguageModelsService } from '../../../chat/common/languageModels.js'; +import { IPromptsService } from '../../../chat/common/promptSyntax/service/promptsService.js'; +import { MockChatVariablesService } from '../../../chat/test/common/mockChatVariables.js'; +import { NullLanguageModelsService } from '../../../chat/test/common/languageModels.js'; +import { MockPromptsService } from '../../../chat/test/common/promptSyntax/service/mockPromptsService.js'; +import { IMcpService } from '../../../mcp/common/mcpTypes.js'; +import { TestMcpService } from '../../../mcp/test/common/testMcpService.js'; +import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js'; +import { INotebookService } from '../../../notebook/common/notebookService.js'; +import { NotebookTextModel } from '../../../notebook/common/model/notebookTextModel.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { InlineChatEditReviewSession } from '../../browser/inlineChatEditReviewSession.js'; +import { IInlineChatSession } from '../../browser/inlineChatSessionService.js'; +import { InlineChatSessionServiceImpl } from '../../browser/inlineChatSessionServiceImpl.js'; +import { IInlineChatSessionResolver, IInlineChatSessionResolution } from '../../browser/inlineChatSessionResolver.js'; +import { TestWorkerService } from './testWorkerService.js'; + +const agentHostContribution: ResolvedChatSessionsExtensionPoint = { + type: SessionType.AgentHostCopilot, + name: 'Agent Host Copilot', + displayName: 'Agent Host Copilot', + description: 'Test contribution', + icon: undefined, + locations: [ChatAgentLocation.EditorInline], +}; + +class TestInlineChatSessionResolver extends mock() { + chatService!: IChatService; + lockToAgent: ResolvedChatSessionsExtensionPoint | undefined; + resolveCalls = 0; + + override async resolve(_token: CancellationToken, _languageId: string | undefined): Promise { + this.resolveCalls++; + return { + modelRef: this.chatService.startNewLocalSession(ChatAgentLocation.EditorInline, { canUseTools: false }), + lockToAgent: this.lockToAgent, + }; + } +} + +interface ReadonlyUpdate { + readonly resource: URI; + readonly value: true | IMarkdownString | false | 'toggle' | 'reset'; +} + +class TestFilesConfigurationService extends mock() { + private readonly _updates = observableValue(this, []); + readonly updates = this._updates; + + override async updateReadonly(resource: URI | URI[], value: true | IMarkdownString | false | 'toggle' | 'reset'): Promise { + for (const item of Array.isArray(resource) ? resource : [resource]) { + this._updates.set([...this._updates.get(), { resource: item, value }], undefined); + } + } + + async waitForUpdates(count: number): Promise { + return waitForState(this.updates.map(updates => updates.length >= count ? updates : undefined)); + } +} + +class TestTextModelService extends mock() { + private readonly _models = new Map(); + private readonly _providers = new Map(); + + override registerTextModelContentProvider(scheme: string, provider: ITextModelContentProvider) { + this._providers.set(scheme, provider); + return toDisposable(() => this._providers.delete(scheme)); + } + + add(model: ITextModel): void { + this._models.set(model.uri.toString(), model); + } + + override async createModelReference(resource: URI): Promise> { + let model = this._models.get(resource.toString()); + if (!model) { + model = await this._providers.get(resource.scheme)?.provideTextContent(resource) ?? undefined; + if (model) { + this.add(model); + } + } + assert.ok(model, `Expected a text model for ${resource}`); + return { + object: { textEditorModel: model } as IResolvedTextEditorModel, + dispose: () => { }, + }; + } +} + +function getAgentData(): IChatAgentData { + return { + name: 'inlineChatTestAgent', + id: 'inlineChatTestAgent', + extensionId: nullExtensionDescription.identifier, + extensionVersion: undefined, + extensionPublisherId: '', + publisherDisplayName: '', + extensionDisplayName: '', + locations: [ChatAgentLocation.EditorInline], + modes: [ChatModeKind.Ask], + metadata: {}, + slashCommands: [], + disambiguation: [], + }; +} + +suite('InlineChatSessionService', () => { + const store = new DisposableStore(); + let service: InlineChatSessionServiceImpl; + let chatService: IChatService; + let modelService: IModelService; + let resolver: TestInlineChatSessionResolver; + let filesConfigurationService: TestFilesConfigurationService; + let textModelService: TestTextModelService; + + setup(() => { + const collection = new ServiceCollection(); + collection.set(IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()); + collection.set(IChatAgentService, new SyncDescriptor(ChatAgentService)); + collection.set(IChatVariablesService, new MockChatVariablesService()); + collection.set(IChatSlashCommandService, new class extends mock() { }); + collection.set(IChatTransferService, new SyncDescriptor(ChatTransferService)); + collection.set(IChatSessionsService, new SyncDescriptor(ChatSessionsService)); + collection.set(IChatEditingService, new SyncDescriptor(ChatEditingService)); + collection.set(IEditorWorkerService, new SyncDescriptor(TestWorkerService)); + collection.set(IChatService, new SyncDescriptor(ChatService)); + collection.set(IMcpService, new TestMcpService()); + collection.set(IPromptsService, new MockPromptsService()); + collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); + collection.set(IChatDebugService, store.add(new ChatDebugServiceImpl(new TestConfigurationService(), store.add(new MockContextKeyService())))); + collection.set(IMultiDiffSourceResolverService, new class extends mock() { + override registerResolver(_resolver: IMultiDiffSourceResolver) { + return Disposable.None; + } + }); + collection.set(IWorkspaceEditingService, new class extends mock() { + override readonly onDidEnterWorkspace = Event.None; + }); + collection.set(INotebookService, new class extends mock() { + override getNotebookTextModel(_uri: URI): NotebookTextModel | undefined { + return undefined; + } + + override hasSupportedNotebooks(_resource: URI): boolean { + return false; + } + }); + + resolver = new TestInlineChatSessionResolver(); + filesConfigurationService = new TestFilesConfigurationService(); + textModelService = new TestTextModelService(); + collection.set(IInlineChatSessionResolver, resolver); + collection.set(IFilesConfigurationService, filesConfigurationService); + collection.set(ITextModelService, textModelService); + + const instantiationService = store.add(store.add(workbenchInstantiationService(undefined, store)).createChild(collection)); + store.add(instantiationService.get(IEditorWorkerService) as TestWorkerService); + store.add(instantiationService.get(IChatSessionsService) as ChatSessionsService); + chatService = instantiationService.get(IChatService); + store.add(chatService as ChatService); + chatService.setSaveModelsEnabled(false); + modelService = instantiationService.get(IModelService); + resolver.chatService = chatService; + + const chatAgentService = instantiationService.get(IChatAgentService); + const agent: IChatAgentImplementation = { + async invoke() { + return {}; + }, + }; + store.add(chatAgentService.registerAgent('inlineChatTestAgent', { ...getAgentData(), isDefault: true })); + store.add(chatAgentService.registerAgentImplementation('inlineChatTestAgent', agent)); + + service = store.add(instantiationService.createInstance(InlineChatSessionServiceImpl)); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses an edit review session and locks to the Agent Host contribution', async () => { + resolver.lockToAgent = agentHostContribution; + + const session = await service.createSession(createEditor(createModel(URI.from({ scheme: Schemas.file, path: '/test/agent-host.ts' }))), false, CancellationToken.None); + + assert.deepStrictEqual({ + usesEditReviewSession: session.editingSession instanceof InlineChatEditReviewSession, + usesChatModelEditingSession: session.editingSession === session.chatModel.editingSession, + locksToContribution: session.lockToAgent === agentHostContribution, + chatModelEditingSession: session.chatModel.editingSession, + }, { + usesEditReviewSession: true, + usesChatModelEditingSession: false, + locksToContribution: true, + chatModelEditingSession: undefined, + }); + + await disposeSession(session); + }); + + test('uses the legacy editing session without Agent Host locking', async () => { + const session = await service.createSession(createEditor(createModel(URI.from({ scheme: Schemas.file, path: '/test/legacy.ts' }))), false, CancellationToken.None); + + assert.deepStrictEqual({ + usesChatModelEditingSession: session.editingSession === session.chatModel.editingSession, + lockToAgent: session.lockToAgent, + readonlyUpdates: filesConfigurationService.updates.get(), + }, { + usesChatModelEditingSession: true, + lockToAgent: undefined, + readonlyUpdates: [], + }); + + await disposeSession(session); + }); + + test('brackets a completed Agent Host turn with a read-only lock', async () => { + resolver.lockToAgent = agentHostContribution; + const uri = URI.from({ scheme: Schemas.file, path: '/test/complete.ts' }); + const session = await service.createSession(createEditor(createModel(uri)), false, CancellationToken.None); + const request = (session.chatModel as ChatModel).addRequest({ text: '', parts: [] }, { variables: [] }, 0); + assert.ok(request.response); + + await filesConfigurationService.waitForUpdates(1); + request.response.complete(); + const updates = await filesConfigurationService.waitForUpdates(2); + + assert.deepStrictEqual(updates.map(update => ({ + resource: update.resource.toString(), + value: update.value === 'reset' ? 'reset' : typeof update.value === 'object' ? 'markdown' : update.value, + })), [ + { resource: uri.toString(), value: 'markdown' }, + { resource: uri.toString(), value: 'reset' }, + ]); + + session.dispose(); + }); + + test('releases the read-only lock when an Agent Host turn is cancelled', async () => { + resolver.lockToAgent = agentHostContribution; + const session = await service.createSession(createEditor(createModel(URI.from({ scheme: Schemas.file, path: '/test/cancelled.ts' }))), false, CancellationToken.None); + const request = (session.chatModel as ChatModel).addRequest({ text: '', parts: [] }, { variables: [] }, 0); + assert.ok(request.response); + + await filesConfigurationService.waitForUpdates(1); + request.response.cancel(); + const updates = await filesConfigurationService.waitForUpdates(2); + + assert.deepStrictEqual(updates.map(update => update.value === 'reset' ? 'reset' : typeof update.value === 'object' ? 'markdown' : update.value), ['markdown', 'reset']); + + await waitForState(session.editingSession.entries.map(entries => entries.length > 0 ? entries : undefined)); + await disposeSession(session); + }); + + test('releases the read-only lock when the inline chat session is disposed mid-turn', async () => { + resolver.lockToAgent = agentHostContribution; + const session = await service.createSession(createEditor(createModel(URI.from({ scheme: Schemas.file, path: '/test/disposed.ts' }))), false, CancellationToken.None); + const request = (session.chatModel as ChatModel).addRequest({ text: '', parts: [] }, { variables: [] }, 0); + assert.ok(request.response); + + await filesConfigurationService.waitForUpdates(1); + session.dispose(); + const updates = await filesConfigurationService.waitForUpdates(2); + + assert.deepStrictEqual(updates.map(update => update.value === 'reset' ? 'reset' : typeof update.value === 'object' ? 'markdown' : update.value), ['markdown', 'reset']); + }); + + test('uses the legacy path without resolving Agent Host for untitled documents', async () => { + resolver.lockToAgent = agentHostContribution; + + const session = await service.createSession(createEditor(createModel(URI.from({ scheme: Schemas.untitled, path: '/test/untitled.ts' }))), false, CancellationToken.None); + + assert.deepStrictEqual({ + resolveCalls: resolver.resolveCalls, + usesChatModelEditingSession: session.editingSession === session.chatModel.editingSession, + readonlyUpdates: filesConfigurationService.updates.get(), + }, { + resolveCalls: 0, + usesChatModelEditingSession: true, + readonlyUpdates: [], + }); + + await disposeSession(session); + }); + + test('uses the legacy path without resolving Agent Host for notebooks', async () => { + resolver.lockToAgent = agentHostContribution; + + const session = await service.createSession(createEditor(createModel(URI.from({ scheme: Schemas.file, path: '/test/notebook.ts' }))), true, CancellationToken.None); + + assert.deepStrictEqual({ + resolveCalls: resolver.resolveCalls, + usesChatModelEditingSession: session.editingSession === session.chatModel.editingSession, + readonlyUpdates: filesConfigurationService.updates.get(), + }, { + resolveCalls: 0, + usesChatModelEditingSession: true, + readonlyUpdates: [], + }); + + await disposeSession(session); + }); + + function createModel(uri: URI): ITextModel { + const model = store.add(modelService.createModel('const value = 1;', null, uri, false)); + textModelService.add(model); + return model; + } + + function createEditor(model: ITextModel): IActiveCodeEditor { + return new class extends mock() { + override getModel(): ITextModel { + return model; + } + + override getSelection(): Selection { + return new Selection(1, 1, 1, 1); + } + }(); + } + + async function disposeSession(session: IInlineChatSession): Promise { + await session.editingSession.reject(); + if (session.editingSession instanceof ChatEditingSession) { + await session.editingSession.stop(); + } + session.dispose(); + } +}); diff --git a/src/vs/workbench/services/filesConfiguration/common/filesConfigurationService.ts b/src/vs/workbench/services/filesConfiguration/common/filesConfigurationService.ts index ab7f8d98f4516d..7311272b6408ee 100644 --- a/src/vs/workbench/services/filesConfiguration/common/filesConfigurationService.ts +++ b/src/vs/workbench/services/filesConfiguration/common/filesConfigurationService.ts @@ -103,8 +103,11 @@ export interface IFilesConfigurationService { isReadonly(resource: URI, stat?: IBaseFileStat): boolean | IMarkdownString; - updateReadonly(resource: URI, readonly: true | false | 'toggle' | 'reset'): Promise; - updateReadonly(resource: URI[], readonly: true | false | 'reset'): Promise; + /** + * Passing an IMarkdownString marks the resource read-only and displays it as the reason instead of the default session read-only message. + */ + updateReadonly(resource: URI, readonly: true | IMarkdownString | false | 'toggle' | 'reset'): Promise; + updateReadonly(resource: URI[], readonly: true | IMarkdownString | false | 'reset'): Promise; //#endregion @@ -159,7 +162,7 @@ export class FilesConfigurationService extends Disposable implements IFilesConfi private readonly readonlyExcludeMatcher = this._register(new GlobalIdleValue(() => this.createReadonlyMatcher(FILES_READONLY_EXCLUDE_CONFIG))); private configuredReadonlyFromPermissions: boolean | undefined; - private readonly sessionReadonlyOverrides = new ResourceMap(resource => this.uriIdentityService.extUri.getComparisonKey(resource)); + private readonly sessionReadonlyOverrides = new ResourceMap(resource => this.uriIdentityService.extUri.getComparisonKey(resource)); constructor( @IContextKeyService contextKeyService: IContextKeyService, @@ -214,6 +217,9 @@ export class FilesConfigurationService extends Disposable implements IFilesConfi if (typeof sessionReadonlyOverride === 'boolean') { return sessionReadonlyOverride === true ? FilesConfigurationService.READONLY_MESSAGES.sessionReadonly : false; } + if (sessionReadonlyOverride !== undefined) { + return sessionReadonlyOverride; + } if ( this.uriIdentityService.extUri.isEqualOrParent(resource, this.environmentService.userRoamingDataHome) || @@ -240,10 +246,10 @@ export class FilesConfigurationService extends Disposable implements IFilesConfi return false; } - async updateReadonly(resource: URI | URI[], readonly: true | false | 'toggle' | 'reset'): Promise { + async updateReadonly(resource: URI | URI[], readonly: true | IMarkdownString | false | 'toggle' | 'reset'): Promise { if (Array.isArray(resource)) { for (const r of resource) { - this.applyReadonly(r, readonly as true | false | 'reset'); + this.applyReadonly(r, readonly as true | IMarkdownString | false | 'reset'); } if (resource.length > 0) { this._onDidChangeReadonly.fire(); @@ -266,7 +272,7 @@ export class FilesConfigurationService extends Disposable implements IFilesConfi this._onDidChangeReadonly.fire(); } - private applyReadonly(resource: URI, readonly: true | false | 'reset'): void { + private applyReadonly(resource: URI, readonly: true | IMarkdownString | false | 'reset'): void { if (readonly === 'reset') { this.sessionReadonlyOverrides.delete(resource); } else { diff --git a/src/vs/workbench/services/filesConfiguration/test/browser/filesConfigurationService.test.ts b/src/vs/workbench/services/filesConfiguration/test/browser/filesConfigurationService.test.ts index e9d82815c07be5..794454ef0fa7ac 100644 --- a/src/vs/workbench/services/filesConfiguration/test/browser/filesConfigurationService.test.ts +++ b/src/vs/workbench/services/filesConfiguration/test/browser/filesConfigurationService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -25,6 +26,50 @@ suite('FilesConfigurationService', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('updateReadonly with a custom reason returns that reason', async () => { + const resource = URI.file('/test/file.txt'); + const reason = new MarkdownString('Custom read-only reason'); + + await service.updateReadonly(resource, reason); + + const readonly = service.isReadonly(resource); + assert.strictEqual(typeof readonly === 'object' ? readonly.value : undefined, reason.value); + }); + + test('updateReadonly with true returns the default session reason', async () => { + const resource = URI.file('/test/file.txt'); + const customReason = new MarkdownString('Custom read-only reason'); + + await service.updateReadonly(resource, true); + + const readonly = service.isReadonly(resource); + assert.ok(typeof readonly === 'object' && readonly.value && readonly.value !== customReason.value); + }); + + test('updateReadonly reset clears a custom reason', async () => { + const resource = URI.file('/test/file.txt'); + + await service.updateReadonly(resource, new MarkdownString('Custom read-only reason')); + await service.updateReadonly(resource, 'reset'); + + assert.strictEqual(service.isReadonly(resource), false); + }); + + test('updateReadonly with an array applies a custom reason to every resource', async () => { + const resources = [ + URI.file('/test/file1.txt'), + URI.file('/test/file2.txt'), + ]; + const reason = new MarkdownString('Custom read-only reason'); + + await service.updateReadonly(resources, reason); + + assert.deepStrictEqual(resources.map(resource => { + const readonly = service.isReadonly(resource); + return typeof readonly === 'object' ? readonly.value : undefined; + }), [reason.value, reason.value]); + }); + test('updateReadonly with single resource fires onDidChangeReadonly once', async () => { const resource = URI.file('/test/file.txt'); let eventCount = 0; diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts index 7a63391ef50889..82889c88de981a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts @@ -336,6 +336,7 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo }()); reg.defineInstance(IChatEditingService, new class extends mock() { override editingSessionsObs = observableValue('editingSessionsObs', []); + override getEditingSession() { return undefined; } }()); reg.defineInstance(IChatInputNotificationService, new class extends mock() { override readonly onDidChange = Event.None; diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 5399919fa4ee7e..635797fe8d30ee 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -769,6 +769,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor reg.defineInstance(ICodeReviewService, codeReviewService); reg.defineInstance(IChatEditingService, new class extends mock() { override readonly editingSessionsObs = constObservable([]); + override getEditingSession() { return undefined; } }()); reg.defineInstance(IAgentSessionsService, new class extends mock() { override readonly model = new class extends mock() { diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index 6ff1608d594e08..968eb2656bfd74 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -7,6 +7,7 @@ import { DeferredPromise, timeout } from '../../../base/common/async.js'; import { bufferToStream, readableToBuffer, VSBuffer, VSBufferReadable } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../base/common/event.js'; +import { IMarkdownString } from '../../../base/common/htmlContent.js'; import { Iterable } from '../../../base/common/iterator.js'; import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../base/common/map.js'; @@ -353,7 +354,7 @@ export const NullFilesConfigurationService = new class implements IFilesConfigur enableAutoSaveAfterShortDelay(resourceOrEditor: URI | EditorInput): IDisposable { throw new Error('Method not implemented.'); } disableAutoSave(resourceOrEditor: URI | EditorInput): IDisposable { throw new Error('Method not implemented.'); } isReadonly(resource: URI, stat?: IBaseFileStat | undefined): boolean { return false; } - async updateReadonly(_resource: URI | URI[], _readonly: boolean | 'toggle' | 'reset'): Promise { } + async updateReadonly(_resource: URI | URI[], _readonly: boolean | IMarkdownString | 'toggle' | 'reset'): Promise { } preventSaveConflicts(resource: URI, language?: string | undefined): boolean { throw new Error('Method not implemented.'); } }; From d70cf0a29790cf6239c9965d90a484b0282836cb Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:16:11 +0200 Subject: [PATCH 02/15] sessions: Align chat pill chevrons and add path hovers (#331794) * Align chat pill chevron and show entry paths in dropdown hovers The chevron glyph is drawn above the middle of its box, so it read as sitting too high next to the label; nudge it onto the label's optical centre. Resource pills tighten their file icon slot and leading padding. Customization entries now carry a hover with the path relative to the session folder that holds them (prefixed with the folder name when the session spans several), falling back to the absolute path. Artifact entries show their URI or link. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Keep pill hover details accessible Separate visual hover content from plain-text descriptions and actionable labels so artifact locations remain visible without replacing pill action names. Cover dropdown forwarding and single-entry ARIA labels. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/sessionArtifacts.ts | 41 +++++++++++---- .../chat/browser/sessionChatInputToolbar.ts | 23 ++++++--- .../chat/browser/sessionCustomizations.ts | 48 ++++++++++++++++-- .../test/browser/sessionArtifacts.test.ts | 50 +++++++++++++++++++ .../browser/sessionCustomizations.test.ts | 41 +++++++++++++-- src/vs/workbench/browser/chatDropdownPill.ts | 9 +++- src/vs/workbench/browser/chatPills.ts | 7 +++ src/vs/workbench/browser/chatResourcePill.ts | 4 ++ src/vs/workbench/browser/media/chatPills.css | 12 +++-- .../test/browser/widget/chatTurnPills.test.ts | 46 ++++++++++++++--- 10 files changed, 245 insertions(+), 36 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts diff --git a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts index 635e5be871381e..4e9d089a8f50dc 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { derived, IObservable, IReader } from '../../../../base/common/observable.js'; import { basename, getComparisonKey } from '../../../../base/common/resources.js'; @@ -51,11 +52,24 @@ function artifactValueKey(artifact: ISessionArtifact): string { return (artifact.link?.toString() ?? artifact.commitHash ?? artifact.id).toLowerCase(); } +function artifactLocation(uri: URI, label: string): Pick { + const value = uri.toString(true); + return { + ariaDescription: value, + ariaLabel: localize('sessionArtifacts.open', "Open {0}", label), + hover: { content: new MarkdownString().appendText(value) }, + tooltip: value, + }; +} + function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions): IChatPillEntry | undefined { if (artifact.kind === SessionArtifactKind.File) { - return artifact.uri - ? { id: artifact.id, label: basename(artifact.uri), resource: artifact.uri, open: () => actions.openResource(artifact.uri!) } - : undefined; + if (!artifact.uri) { + return undefined; + } + const uri = artifact.uri; + const label = basename(uri); + return { id: artifact.id, label, resource: uri, ...artifactLocation(uri, label), open: () => actions.openResource(uri) }; } const icon = artifactIcons.get(artifact.kind) ?? Codicon.archive; @@ -72,18 +86,22 @@ function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions): run: () => actions.copy(artifact.commitHash!), })] : []; - return { id: artifact.id, label: artifact.label, icon, toolbarActions: copyAction, open: () => actions.openExternal(link) }; + return { id: artifact.id, label: artifact.label, icon, toolbarActions: copyAction, ...artifactLocation(link, artifact.label), open: () => actions.openExternal(link) }; } if (artifact.kind === SessionArtifactKind.Resource) { - return artifact.uri - ? { id: artifact.id, label: artifact.label, icon, open: () => actions.openResource(artifact.uri!) } - : undefined; + if (!artifact.uri) { + return undefined; + } + const uri = artifact.uri; + return { id: artifact.id, label: artifact.label, icon, ...artifactLocation(uri, artifact.label), open: () => actions.openResource(uri) }; } - return artifact.link - ? { id: artifact.id, label: artifact.label, icon, open: () => actions.openExternal(artifact.link!) } - : undefined; + if (!artifact.link) { + return undefined; + } + const link = artifact.link; + return { id: artifact.id, label: artifact.label, icon, ...artifactLocation(link, artifact.label), open: () => actions.openExternal(link) }; } /** @@ -112,7 +130,8 @@ export function buildSessionArtifactSections(artifacts: readonly ISessionArtifac } seen.add(getComparisonKey(file.uri)); const entries = entriesByKind.get(SessionArtifactKind.File) ?? []; - entries.push({ id: file.uri.toString(), label: basename(file.uri), resource: file.uri, open: () => actions.openResource(file.uri) }); + const label = basename(file.uri); + entries.push({ id: file.uri.toString(), label, resource: file.uri, ...artifactLocation(file.uri, label), open: () => actions.openResource(file.uri) }); entriesByKind.set(SessionArtifactKind.File, entries); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index e40a5a468a8be2..e15b8d272fe245 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -7,6 +7,7 @@ import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindo import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { toAction, Action, Separator, type IAction } from '../../../../base/common/actions.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; @@ -59,12 +60,20 @@ export function shouldShowSessionTurnPills(hasDebugData: boolean, turnActive: bo /** Fake artifacts for the pill debug overlay. */ function buildDebugArtifactSections(debugData: ISessionChatPillsDebugData): readonly IChatPillSection[] { - const entries = debugData.markdownFiles.map(name => ({ - id: name, - label: name, - resource: URI.from({ scheme: 'session-chat-pills-debug', path: `/${name}` }), - open: () => { }, - })); + const entries = debugData.markdownFiles.map(name => { + const resource = URI.from({ scheme: 'session-chat-pills-debug', path: `/${name}` }); + const location = resource.toString(true); + return { + id: name, + label: name, + resource, + ariaDescription: location, + ariaLabel: localize('sessionArtifacts.open', "Open {0}", name), + hover: { content: new MarkdownString().appendText(location) }, + tooltip: location, + open: () => { }, + }; + }); return entries.length ? [{ title: localize('sessionArtifacts.files', "Files"), entries }] : []; } @@ -173,7 +182,7 @@ export class SessionChatInputToolbar extends Disposable { const debugData = this._debugData.read(reader); return debugData ? buildDebugArtifactSections(debugData) : sessionArtifacts.sections.read(reader); }); - const sessionCustomizations = this._register(instantiationService.createInstance(SessionCustomizations, this._chat)); + const sessionCustomizations = this._register(instantiationService.createInstance(SessionCustomizations, this._chat, this._session)); this._customizationSections = sessionCustomizations.sections; const turnStatusPillsEnabled = observeTurnStatusPillsEnabled(this._configurationService); diff --git a/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts index 5d995534e71557..fd3393c71b9fa0 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts @@ -4,15 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; +import { isMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; import { derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { isEqualOrParent, relativePath } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; -import { ISessionChatCustomization, SessionCustomizationKind, type IChat } from '../../../services/sessions/common/session.js'; +import { ISessionChatCustomization, ISessionFolder, SessionCustomizationKind, type IChat } from '../../../services/sessions/common/session.js'; +import type { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; /** Action id of the customizations pill. */ export const SESSION_CUSTOMIZATIONS_PILL_ID = 'sessions.chatPills.customizations'; @@ -66,15 +71,19 @@ const sectionOrder: readonly { readonly kind: SessionCustomizationKind; readonly /** Builds the dropdown sections, preserving the order customizations appeared in. */ export function buildSessionCustomizationSections( customizations: readonly ISessionChatCustomization[], + sessionFolders: readonly ISessionFolder[], reveal: (customization: ISessionChatCustomization) => void, ): readonly IChatPillSection[] { const entriesByKind = new Map(); for (const customization of customizations) { const entries = entriesByKind.get(customization.kind) ?? []; + const path = customization.uri ? getCustomizationPath(customization.uri, sessionFolders) : undefined; entries.push({ id: customization.id, label: customization.name, icon: customizationIcons.get(customization.kind) ?? Codicon.bookmark, + ariaDescription: path, + hover: path ? { content: new MarkdownString().appendText(path) } : undefined, open: () => reveal(customization), }); entriesByKind.set(customization.kind, entries); @@ -90,19 +99,42 @@ export function buildSessionCustomizationSections( return sections; } +/** + * The path shown beside a customization: relative to the session folder holding + * it (prefixed with the folder name when the session spans several), else absolute. + */ +function getCustomizationPath(uri: URI, sessionFolders: readonly ISessionFolder[]): string { + for (const folder of sessionFolders) { + if (!isEqualOrParent(uri, folder.workingDirectory)) { + continue; + } + const path = relativePath(folder.workingDirectory, uri); + if (path === undefined) { + continue; + } + if (!path) { + return folder.name; + } + return sessionFolders.length > 1 ? `${folder.name}/${path}` : path; + } + return uri.scheme === Schemas.file ? uri.fsPath : uri.toString(true); +} + /** Publishes the active chat's customization sections for the chat input pill. */ export class SessionCustomizations extends Disposable { readonly sections: IObservable; constructor( chat: IObservable, + session: IObservable, @ICommandService private readonly _commandService: ICommandService, ) { super(); this.sections = derivedOpts({ owner: this, equalsFn: sectionsEqual }, reader => { const customizations = chat.read(reader)?.customizations?.read(reader) ?? []; - return buildSessionCustomizationSections(customizations, customization => this._reveal(customization)); + const sessionFolders = session.read(reader)?.workspace.read(reader)?.folders ?? []; + return buildSessionCustomizationSections(customizations, sessionFolders, customization => this._reveal(customization)); }); } @@ -121,5 +153,15 @@ export class SessionCustomizations extends Disposable { function sectionsEqual(a: readonly IChatPillSection[], b: readonly IChatPillSection[]): boolean { return a.length === b.length && a.every((section, i) => section.title === b[i].title && section.entries.length === b[i].entries.length - && section.entries.every((entry, j) => entry.id === b[i].entries[j].id && entry.label === b[i].entries[j].label)); + && section.entries.every((entry, j) => entry.id === b[i].entries[j].id + && entry.label === b[i].entries[j].label + && hoverContentEqual(entry, b[i].entries[j]))); +} + +function hoverContentEqual(a: IChatPillEntry, b: IChatPillEntry): boolean { + const aContent = a.hover?.content; + const bContent = b.hover?.content; + return isMarkdownString(aContent) && isMarkdownString(bContent) + ? aContent.value === bContent.value + : aContent === bContent; } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts new file mode 100644 index 00000000000000..cdda336a07b8ce --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.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 assert from 'assert'; +import { isMarkdownString } from '../../../../../base/common/htmlContent.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { buildSessionArtifactSections, type ISessionArtifactActions } from '../../browser/sessionArtifacts.js'; +import { type ISessionArtifact, SessionArtifactKind, SessionFileOperation } from '../../../../services/sessions/common/session.js'; + +suite('Session Artifacts', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const actions: ISessionArtifactActions = { + openExternal() { }, + openResource() { }, + copy() { }, + }; + + test('shows each artifact URI or link beside its dropdown entry', () => { + const fileUri = URI.file('/artifacts/report.md'); + const externalFileUri = URI.file('/external/plan.md'); + const resourceUri = URI.parse('vscode://sessions/resource'); + const pullRequestLink = URI.parse('https://github.com/microsoft/vscode/pull/12'); + const artifacts: readonly ISessionArtifact[] = [ + { id: 'pr', kind: SessionArtifactKind.PullRequest, label: 'PR #12', link: pullRequestLink }, + { id: 'file', kind: SessionArtifactKind.File, label: 'Report', uri: fileUri }, + { id: 'resource', kind: SessionArtifactKind.Resource, label: 'Resource', uri: resourceUri }, + ]; + + const entries = buildSessionArtifactSections(artifacts, [{ uri: externalFileUri, operation: SessionFileOperation.Created }], actions).flatMap(section => section.entries); + assert.deepStrictEqual(entries.map(entry => { + const content = entry.hover?.content; + return { + label: entry.label, + ariaLabel: entry.ariaLabel, + ariaDescription: entry.ariaDescription, + hover: isMarkdownString(content) ? content.value : undefined, + tooltip: entry.tooltip, + }; + }), [ + { label: 'PR #12', ariaLabel: 'Open PR #12', ariaDescription: pullRequestLink.toString(true), hover: pullRequestLink.toString(true), tooltip: pullRequestLink.toString(true) }, + { label: 'report.md', ariaLabel: 'Open report.md', ariaDescription: fileUri.toString(true), hover: fileUri.toString(true), tooltip: fileUri.toString(true) }, + { label: 'plan.md', ariaLabel: 'Open plan.md', ariaDescription: externalFileUri.toString(true), hover: externalFileUri.toString(true), tooltip: externalFileUri.toString(true) }, + { label: 'Resource', ariaLabel: 'Open Resource', ariaDescription: resourceUri.toString(true), hover: resourceUri.toString(true), tooltip: resourceUri.toString(true) }, + ]); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionCustomizations.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionCustomizations.test.ts index 30e3327e4f6b7d..949479c05f2c38 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionCustomizations.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionCustomizations.test.ts @@ -4,16 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { isMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { buildSessionCustomizationSections } from '../../browser/sessionCustomizations.js'; -import { ISessionChatCustomization, SessionCustomizationKind } from '../../../../services/sessions/common/session.js'; +import { ISessionChatCustomization, ISessionFolder, SessionCustomizationKind } from '../../../../services/sessions/common/session.js'; suite('Session Customizations', () => { ensureNoDisposablesAreLeakedInTestSuite(); const customization = (id: string, kind: SessionCustomizationKind, name: string): ISessionChatCustomization => ({ id, kind, name, uri: URI.file(`/repo/${id}.md`) }); + const sessionFolder = (name: string, workingDirectory: URI): ISessionFolder => ({ + root: workingDirectory, + workingDirectory, + name, + description: undefined, + }); test('groups into typed sections in a fixed order, keeping arrival order within a section', () => { const sections = buildSessionCustomizationSections([ @@ -22,7 +29,7 @@ suite('Session Customizations', () => { customization('c3', SessionCustomizationKind.Instruction, 'writing-tests'), customization('c4', SessionCustomizationKind.Skill, 'unit-tests'), customization('c5', SessionCustomizationKind.Agent, 'rubber-duck'), - ], () => { }); + ], [], () => { }); assert.deepStrictEqual(sections.map(section => ({ title: section.title, entries: section.entries.map(entry => entry.label) })), [ { title: 'Agents', entries: ['rubber-duck'] }, @@ -36,6 +43,7 @@ suite('Session Customizations', () => { const revealed: string[] = []; const sections = buildSessionCustomizationSections( [customization('c1', SessionCustomizationKind.Skill, 'sessions')], + [], target => revealed.push(target.id), ); sections[0].entries[0].open(); @@ -43,7 +51,34 @@ suite('Session Customizations', () => { assert.deepStrictEqual(revealed, ['c1']); }); + test('shows paths relative to session working directories', () => { + const singleFolder = [sessionFolder('repo', URI.file('/repo'))]; + const multipleFolders = [ + sessionFolder('client', URI.file('/work/client')), + sessionFolder('server', URI.file('/work/server')), + ]; + const outside = URI.file('/global/customizations/global.md'); + const hover = (customization: ISessionChatCustomization, folders: readonly ISessionFolder[]) => { + const entry = buildSessionCustomizationSections([customization], folders, () => { })[0].entries[0]; + const content = entry.hover?.content; + return { + ariaDescription: entry.ariaDescription, + content: isMarkdownString(content) ? content.value : undefined, + }; + }; + + assert.deepStrictEqual({ + singleFolder: hover(customization('c1', SessionCustomizationKind.Skill, 'sessions'), singleFolder), + multipleFolders: hover({ ...customization('c2', SessionCustomizationKind.Instruction, 'instructions'), uri: URI.file('/work/server/.github/instructions/review.md') }, multipleFolders), + outside: hover({ ...customization('c3', SessionCustomizationKind.Prompt, 'global'), uri: outside }, singleFolder), + }, { + singleFolder: { ariaDescription: 'c1.md', content: 'c1.md' }, + multipleFolders: { ariaDescription: 'server/.github/instructions/review.md', content: 'server/.github/instructions/review.md' }, + outside: { ariaDescription: outside.fsPath, content: new MarkdownString().appendText(outside.fsPath).value }, + }); + }); + test('no customizations yields no sections', () => { - assert.deepStrictEqual(buildSessionCustomizationSections([], () => { }), []); + assert.deepStrictEqual(buildSessionCustomizationSections([], [], () => { }), []); }); }); diff --git a/src/vs/workbench/browser/chatDropdownPill.ts b/src/vs/workbench/browser/chatDropdownPill.ts index 5d2803d1d34297..2f2cb8f2fbaf3e 100644 --- a/src/vs/workbench/browser/chatDropdownPill.ts +++ b/src/vs/workbench/browser/chatDropdownPill.ts @@ -137,6 +137,12 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { return entry?.tooltip ?? entry?.label ?? this._pillOptions.title; } + protected override getAriaLabel(): string | undefined { + return this.isSummarized + ? this._pillOptions.summaryAriaLabel(this.entries.length) + : this.entries.at(0)?.ariaLabel ?? super.getAriaLabel(); + } + protected override onDidClickButton(): void { if (!this.isSummarized) { this.openEntry(this.entries.at(0)); @@ -180,6 +186,8 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { group: { title: '', ...(entry.icon ? { icon: entry.icon } : {}) }, ...(entry.resource ? { iconClasses: getIconClasses(this._modelService, this._languageService, entry.resource, FileKind.FILE) } : {}), ...(entry.toolbarActions?.length ? { toolbarActions: [...entry.toolbarActions] } : {}), + ariaDescription: entry.ariaDescription, + hover: entry.hover, item: entry, }); } @@ -243,4 +251,3 @@ export function createChatSectionPill( ? { action, createActionViewItem: viewItemOptions => new ChatResourcePillActionViewItem(action, viewItemOptions, singleResourceEntry, resourceLabels) } : { action, createActionViewItem: viewItemOptions => instantiationService.createInstance(ChatDropdownPillActionViewItem, action, viewItemOptions, sections, options) }); } - diff --git a/src/vs/workbench/browser/chatPills.ts b/src/vs/workbench/browser/chatPills.ts index 7194cc9acdad22..1cfc303fb0a6d9 100644 --- a/src/vs/workbench/browser/chatPills.ts +++ b/src/vs/workbench/browser/chatPills.ts @@ -15,6 +15,7 @@ import { autorun, derived, IObservable } from '../../base/common/observable.js'; import { ThemeIcon } from '../../base/common/themables.js'; import { URI } from '../../base/common/uri.js'; import { localize } from '../../nls.js'; +import type { IActionListItemHover } from '../../platform/actionWidget/browser/actionList.js'; import { IContextMenuService } from '../../platform/contextview/browser/contextView.js'; import { defaultButtonStyles } from '../../platform/theme/browser/defaultStyles.js'; import './media/chatPills.css'; @@ -43,6 +44,12 @@ export interface IChatPillEntry { readonly resource?: URI; /** Actions shown at the trailing edge of the entry's dropdown row. */ readonly toolbarActions?: readonly IAction[]; + /** Accessible name used when this entry is rendered as the pill itself. */ + readonly ariaLabel?: string; + /** Plain-text description of the content shown beside the dropdown entry. */ + readonly ariaDescription?: string; + /** Content shown beside the entry while it is focused or hovered. */ + readonly hover?: IActionListItemHover; /** Tooltip for the pill when this is the only entry. */ readonly tooltip?: string; open(): void; diff --git a/src/vs/workbench/browser/chatResourcePill.ts b/src/vs/workbench/browser/chatResourcePill.ts index ec2085f11fb4f9..27df706b631449 100644 --- a/src/vs/workbench/browser/chatResourcePill.ts +++ b/src/vs/workbench/browser/chatResourcePill.ts @@ -48,6 +48,10 @@ export class ChatResourcePillActionViewItem extends ChatPillActionViewItemBase { return entry?.tooltip ?? (entry ? localize('chatResourcePill.open', "Open {0}", entry.label) : this._action.label); } + protected override getAriaLabel(): string | undefined { + return this._entry.get()?.ariaLabel ?? super.getAriaLabel(); + } + protected override onDidClickButton(): void { try { this._entry.get()?.open(); diff --git a/src/vs/workbench/browser/media/chatPills.css b/src/vs/workbench/browser/media/chatPills.css index 434ecc547992ea..a43ed61f4cef19 100644 --- a/src/vs/workbench/browser/media/chatPills.css +++ b/src/vs/workbench/browser/media/chatPills.css @@ -115,8 +115,8 @@ white-space: nowrap; } -/* The chevron is a codicon like the leading glyph, so it needs the same - compact box to sit on the label's baseline. */ +/* The chevron glyph is drawn above the middle of its box, so nudge it onto the + label's optical centre. */ .monaco-workbench .chat-pill-chevron.codicon[class*='codicon-'] { display: inline-flex; align-items: center; @@ -126,6 +126,12 @@ margin: 0; font-size: var(--vscode-codiconFontSize-compact); flex-shrink: 0; + transform: translateY(1px); +} + +/* The themed file icon carries its own leading gap, so the pill adds barely any. */ +.chat-pill-item .monaco-button.chat-resource-pill-button { + padding-left: var(--vscode-spacing-size20); } .chat-resource-pill-button .monaco-icon-label { @@ -138,7 +144,7 @@ .chat-resource-pill-button .monaco-icon-label::before { background-size: var(--vscode-codiconFontSize-compact); width: var(--vscode-codiconFontSize-compact); - height: var(--vscode-codiconFontSize-compact); + height: 14px; } /* File rows render a themed file icon in the same slot codicon rows use, so diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts index 0deedb69795b71..9e7af4ba00c8d5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts @@ -86,6 +86,34 @@ suite('ChatTurnPills', () => { ]); }); + test('keeps an actionable accessible name when a single entry has a location tooltip', () => { + const instantiationService = workbenchInstantiationService(undefined, disposables); + const action = disposables.add(new Action('test.pill', 'Artifact')); + const resourceLabels = disposables.add(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + const entry: IChatPillEntry = { + id: 'plan', + label: 'plan.md', + resource: URI.file('/repo/plan.md'), + ariaLabel: 'Open plan.md', + tooltip: 'file:///repo/plan.md', + open: () => { }, + }; + const items: readonly IActionViewItem[] = [ + disposables.add(new ChatResourcePillActionViewItem(action, {}, constObservable(entry), resourceLabels)), + disposables.add(instantiationService.createInstance(ChatDropdownPillActionViewItem, action, {}, constObservable([{ title: 'Files', entries: [entry] }]), chatArtifactPillOptions)), + ]; + + const ariaLabels = items.map(item => { + const container = document.createElement('div'); + mainWindow.document.body.appendChild(container); + disposables.add(toDisposable(() => container.remove())); + item.render(container); + return container.querySelector('.monaco-button')?.getAttribute('aria-label'); + }); + + assert.deepStrictEqual(ariaLabels, ['Open plan.md', 'Open plan.md']); + }); + test('focusing a pill restores its tab stop, so the row stays reachable by Tab', () => { const action = disposables.add(new Action('test.pill', 'Pull Requests')); const item = disposables.add(new ChatPillActionViewItem(undefined, action, {})); @@ -168,18 +196,18 @@ suite('ChatTurnPills', () => { test('summarizes multiple artifacts and groups the dropdown by section', () => { const instantiationService = workbenchInstantiationService(undefined, disposables); - let shownItems: readonly { readonly kind: ActionListItemKind; readonly label: string | undefined }[] = []; + let shownItems: readonly { readonly kind: ActionListItemKind; readonly label: string | undefined; readonly ariaDescription: string | undefined; readonly hover: string | undefined }[] = []; instantiationService.stub(IActionWidgetService, new class extends mock() { override get isVisible(): boolean { return false; } override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[]): void { - shownItems = items.map(item => ({ kind: item.kind, label: item.label })); + shownItems = items.map(item => ({ kind: item.kind, label: item.label, ariaDescription: item.ariaDescription, hover: typeof item.hover?.content === 'string' ? item.hover.content : undefined })); } }); const opened: string[] = []; const widget = disposables.add(instantiationService.createInstance(ChatTurnPillsWidget, { stats: constObservable(EMPTY_DIFF_STATS), artifacts: constObservable([ - { title: 'Pull Requests', entries: [{ id: 'pr', label: '#12', icon: Codicon.gitPullRequest, open: () => opened.push('pr') }] }, + { title: 'Pull Requests', entries: [{ id: 'pr', label: '#12', icon: Codicon.gitPullRequest, ariaDescription: 'Pull request URL', hover: { content: 'https://github.com/microsoft/vscode/pull/12' }, open: () => opened.push('pr') }] }, { title: 'Files', entries: [{ id: 'file', label: 'plan.md', resource: URI.file('/artifacts/plan.md'), open: () => opened.push('file') }] }, ]), changesEnabled: constObservable(false), @@ -200,16 +228,18 @@ suite('ChatTurnPills', () => { dropdownItems: shownItems.map(item => ({ kind: item.kind, label: item.label, + ariaDescription: item.ariaDescription, + hover: item.hover, })), }, { label: '2 Artifacts', ariaLabel: 'Show 2 artifacts', dropdownItems: [ - { kind: ActionListItemKind.Header, label: 'Pull Requests' }, - { kind: ActionListItemKind.Action, label: '#12' }, - { kind: ActionListItemKind.Separator, label: '' }, - { kind: ActionListItemKind.Header, label: 'Files' }, - { kind: ActionListItemKind.Action, label: 'plan.md' }, + { kind: ActionListItemKind.Header, label: 'Pull Requests', ariaDescription: undefined, hover: undefined }, + { kind: ActionListItemKind.Action, label: '#12', ariaDescription: 'Pull request URL', hover: 'https://github.com/microsoft/vscode/pull/12' }, + { kind: ActionListItemKind.Separator, label: '', ariaDescription: undefined, hover: undefined }, + { kind: ActionListItemKind.Header, label: 'Files', ariaDescription: undefined, hover: undefined }, + { kind: ActionListItemKind.Action, label: 'plan.md', ariaDescription: undefined, hover: undefined }, ], }); }); From 1750b44937dbdb34f5843d1e05a64720651cf312 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 20 Aug 2026 16:19:08 -0700 Subject: [PATCH 03/15] server: reject agent commands in remote CLI (#331877) * server: reject agent commands in remote CLI Prevent the remote CLI from treating unsupported agent subcommands as file paths. - Detect agent commands before the remote CLI filters and parses its options. - Return a clear error and nonzero exit code instead of opening command arguments as files. - Add focused tests for global options, option values, and the option terminator. Fixes https://github.com/microsoft/vscode/issues/329934 (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * server: align agent guard parsing - Recognize deprecated option IDs when skipping option values. - Stop scanning when another top-level subcommand appears first. - Add regression coverage for deprecated options and competing subcommands. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/server/node/server.cli.ts | 7 +++ src/vs/server/node/server.cliAgent.ts | 48 +++++++++++++++++++ .../server/test/node/server.cliAgent.test.ts | 44 +++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 src/vs/server/node/server.cliAgent.ts create mode 100644 src/vs/server/test/node/server.cliAgent.test.ts diff --git a/src/vs/server/node/server.cli.ts b/src/vs/server/node/server.cli.ts index 4c4ca67c7e0249..040656c01ecce1 100644 --- a/src/vs/server/node/server.cli.ts +++ b/src/vs/server/node/server.cli.ts @@ -16,6 +16,7 @@ import { PipeCommand } from '../../workbench/api/node/extHostCLIServer.js'; import { hasStdinWithoutTty, getStdinFilePath, readFromStdin } from '../../platform/environment/node/stdin.js'; import { DeferredPromise } from '../../base/common/async.js'; import { FileAccess } from '../../base/common/network.js'; +import { hasAgentCommand } from './server.cliAgent.js'; /* * Implements a standalone CLI app that opens VS Code from a remote terminal. @@ -95,6 +96,12 @@ export async function main(desc: ProductDescription, args: string[]): Promise> = { ...OPTIONS, gitCredential: { type: 'string' }, openExternal: { type: 'boolean' } }; const isSupported = cliCommand ? isSupportedForCmd : isSupportedForPipe; diff --git a/src/vs/server/node/server.cliAgent.ts b/src/vs/server/node/server.cliAgent.ts new file mode 100644 index 00000000000000..c1f5b51b339baf --- /dev/null +++ b/src/vs/server/node/server.cliAgent.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 { OPTIONS, type Option } from '../../platform/environment/node/argv.js'; + +export function hasAgentCommand(args: readonly string[]): boolean { + let valueForOption = false; + for (const arg of args) { + if (valueForOption) { + valueForOption = false; + continue; + } + if (arg === '--') { + return false; + } + if (arg === 'agent') { + return true; + } + if (Object.entries(OPTIONS).some(([id, option]) => id === arg && option.type === 'subcommand')) { + return false; + } + const option = getOption(arg); + if (option?.type === 'string' || option?.type === 'string[]') { + valueForOption = true; + } + } + return false; +} + +function getOption(arg: string): Option<'boolean'> | Option<'string'> | Option<'string[]'> | undefined { + if (!arg.startsWith('-') || arg.includes('=')) { + return undefined; + } + const id = arg.startsWith('--') ? arg.slice(2) : arg.slice(1); + for (const [optionId, option] of Object.entries(OPTIONS)) { + if (option.type !== 'subcommand' && (id === option.alias || id === optionId || option.deprecates?.includes(id))) { + return option; + } + } + for (const [optionId, option] of Object.entries(OPTIONS['agent'].options)) { + if (id === option.alias || id === optionId) { + return option; + } + } + return undefined; +} diff --git a/src/vs/server/test/node/server.cliAgent.test.ts b/src/vs/server/test/node/server.cliAgent.test.ts new file mode 100644 index 00000000000000..394199a4438e51 --- /dev/null +++ b/src/vs/server/test/node/server.cliAgent.test.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { hasAgentCommand } from '../../node/server.cliAgent.js'; + +suite('Server CLI agent command guard', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('detects agent endpoints', () => { + assert.strictEqual(hasAgentCommand(['agent', 'endpoints', '--user-data-dir', '/home/tester/.vscode-remote']), true); + }); + + test('detects agent after global native CLI options', () => { + assert.strictEqual(hasAgentCommand(['--cli-data-dir', '/x', 'agent', 'endpoints', '--user-data-dir', '/y']), true); + }); + + test('ignores non-agent commands', () => { + assert.strictEqual(hasAgentCommand(['--version']), false); + }); + + test('does not treat a global option value as an agent command', () => { + assert.strictEqual(hasAgentCommand(['--profile', 'agent']), false); + }); + + test('does not treat a deprecated global option value as an agent command', () => { + assert.strictEqual(hasAgentCommand(['--extensionHomePath', 'agent']), false); + }); + + test('stops at the first recognized subcommand', () => { + assert.deepStrictEqual([ + hasAgentCommand(['chat', 'agent']), + hasAgentCommand(['serve-web', 'agent']), + hasAgentCommand(['tunnel', 'agent']), + ], [false, false, false]); + }); + + test('does not detect agent after the option terminator', () => { + assert.strictEqual(hasAgentCommand(['--', 'agent', 'endpoints']), false); + }); +}); From c21b01c8db3136bf20f0e45baf3eb4b32baeab8c Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 20 Aug 2026 16:24:30 -0700 Subject: [PATCH 04/15] Defer breadcrumb reveal layout measurement (#331880) Defer breadcrumbs reveal layout measurement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ui/breadcrumbs/breadcrumbsWidget.ts | 25 ++++++-- .../ui/breadcrumbs/breadcrumbsWidget.test.ts | 60 +++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 src/vs/base/test/browser/ui/breadcrumbs/breadcrumbsWidget.test.ts diff --git a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts index 9ae1e420207951..4b4eff2378d201 100644 --- a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts +++ b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts @@ -10,7 +10,7 @@ import { DomScrollableElement } from '../scrollbar/scrollableElement.js'; import { commonPrefixLength } from '../../../common/arrays.js'; import { ThemeIcon } from '../../../common/themables.js'; import { Emitter, Event } from '../../../common/event.js'; -import { DisposableStore, dispose, IDisposable } from '../../../common/lifecycle.js'; +import { DisposableStore, dispose, IDisposable, MutableDisposable } from '../../../common/lifecycle.js'; import { ScrollbarVisibility } from '../../../common/scrollable.js'; import './breadcrumbsWidget.css'; @@ -60,6 +60,7 @@ export class BreadcrumbsWidget { private _pendingDimLayout: IDisposable | undefined; private _pendingLayout: IDisposable | undefined; + private readonly _pendingReveal = this._disposables.add(new MutableDisposable()); private _dimension: dom.Dimension | undefined; constructor( @@ -67,7 +68,8 @@ export class BreadcrumbsWidget { horizontalScrollbarSize: number, horizontalScrollbarVisibility: ScrollbarVisibility = ScrollbarVisibility.Auto, separatorIcon: ThemeIcon, - styles: IBreadcrumbsWidgetStyles + styles: IBreadcrumbsWidgetStyles, + private readonly _measure: typeof dom.measure = dom.measure, ) { this._domNode = document.createElement('div'); this._domNode.className = 'monaco-breadcrumbs'; @@ -241,6 +243,7 @@ export class BreadcrumbsWidget { } private _reveal(nth: number, minimal: boolean): void { + this._pendingReveal.clear(); if (nth < 0 || nth >= this._nodes.length) { return; } @@ -248,11 +251,25 @@ export class BreadcrumbsWidget { if (!node) { return; } + if (!minimal) { + this._pendingReveal.value = this._measure(dom.getWindow(this._domNode), () => { + if (this._nodes[nth] === node) { + this._revealNode(node, false); + } + }); + return; + } + + this._revealNode(node, true); + } + + private _revealNode(node: HTMLElement, minimal: boolean): void { const { width } = this._scrollable.getScrollDimensions(); const { scrollLeft } = this._scrollable.getScrollPosition(); - if (!minimal || node.offsetLeft > scrollLeft + width || node.offsetLeft < scrollLeft) { + const nodeOffsetLeft = node.offsetLeft; + if (!minimal || nodeOffsetLeft > scrollLeft + width || nodeOffsetLeft < scrollLeft) { this._scrollable.setRevealOnScroll(false); - this._scrollable.setScrollPosition({ scrollLeft: node.offsetLeft }); + this._scrollable.setScrollPosition({ scrollLeft: nodeOffsetLeft }); this._scrollable.setRevealOnScroll(true); } } diff --git a/src/vs/base/test/browser/ui/breadcrumbs/breadcrumbsWidget.test.ts b/src/vs/base/test/browser/ui/breadcrumbs/breadcrumbsWidget.test.ts new file mode 100644 index 00000000000000..bcebb935873165 --- /dev/null +++ b/src/vs/base/test/browser/ui/breadcrumbs/breadcrumbsWidget.test.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { BreadcrumbsItem, BreadcrumbsWidget, IBreadcrumbsWidgetStyles } from '../../../../browser/ui/breadcrumbs/breadcrumbsWidget.js'; +import { Codicon } from '../../../../common/codicons.js'; +import { IDisposable, toDisposable } from '../../../../common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; + +class TestBreadcrumbsItem extends BreadcrumbsItem { + + constructor(private readonly label: string) { + super(); + } + + override dispose(): void { } + + override equals(other: BreadcrumbsItem): boolean { + return other instanceof TestBreadcrumbsItem && other.label === this.label; + } + + override render(container: HTMLElement): void { + container.textContent = this.label; + } +} + +const styles: IBreadcrumbsWidgetStyles = { + breadcrumbsBackground: undefined, + breadcrumbsForeground: undefined, + breadcrumbsFocusForeground: undefined, + breadcrumbsFocusAndSelectionForeground: undefined, + breadcrumbsHoverForeground: undefined, +}; + +suite('BreadcrumbsWidget', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('defers non-focus reveals and cancels them when focus changes', () => { + const operations: string[] = []; + const measure = (_targetWindow: Window, _callback: () => void): IDisposable => { + operations.push('schedule'); + return toDisposable(() => operations.push('cancel')); + }; + const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + const widget = store.add(new BreadcrumbsWidget(container, 3, undefined, Codicon.chevronRight, styles, measure)); + const first = new TestBreadcrumbsItem('first'); + const last = new TestBreadcrumbsItem('last'); + widget.setItems([first, last]); + + widget.revealLast(); + widget.reveal(first); + widget.domFocus(); + + assert.deepStrictEqual(operations, ['schedule', 'cancel', 'schedule', 'cancel']); + }); +}); From 3eb0bec8621ecfd2ab5b44a4b8f10124064d6646 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 20 Aug 2026 16:44:26 -0700 Subject: [PATCH 05/15] Pause plugin auto-updates on metered connections (#331694) * chat: pause plugin auto-updates on metered connections Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: avoid plugin update lifecycle races Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: simplify metered plugin updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Preserve plugin update startup idle gate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: coalesce plugin update checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle cancelled plugin update checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Coordinate plugin checks with queued updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/pluginAutoUpdate.ts | 23 +- .../plugins/pluginMarketplaceService.ts | 89 +++-- .../browser/plugins/pluginAutoUpdate.test.ts | 113 +++++- .../plugins/pluginMarketplaceService.test.ts | 329 +++++++++++++++++- 4 files changed, 505 insertions(+), 49 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts index 28c761987f1570..712cb3c1d84954 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts @@ -7,6 +7,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; import { IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; @@ -36,6 +37,7 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, @IPluginInstallService private readonly _pluginInstallService: IPluginInstallService, @ILogService private readonly _logService: ILogService, + @IMeteredConnectionService private readonly _meteredConnectionService: IMeteredConnectionService, ) { super(); @@ -46,10 +48,23 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi } void this._triggerAutoUpdate(marketplaceIds); })); + + this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { + if (!isMetered) { + this._triggerQueuedAutoUpdate(); + } + })); + } + + private _triggerQueuedAutoUpdate(): void { + const marketplaceIds = this._pluginMarketplaceService.marketplacesWithUpdates.get(); + if (marketplaceIds.size > 0) { + void this._triggerAutoUpdate(marketplaceIds); + } } private async _triggerAutoUpdate(marketplaceIds: ReadonlySet): Promise { - if (this._updateInFlight) { + if (this._store.isDisposed || this._updateInFlight || this._meteredConnectionService.isConnectionMetered) { return; } @@ -59,8 +74,12 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi } catch (err) { this._logService.error('[PluginAutoUpdate] Failed to auto-update plugins:', err); } finally { - this._updateInFlight = false; this._pluginMarketplaceService.clearUpdatesAvailable(marketplaceIds); + this._updateInFlight = false; + + if (!this._store.isDisposed && !this._meteredConnectionService.isConnectionMetered) { + this._triggerQueuedAutoUpdate(); + } } } } diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index bb0c3d7a3bbfed..141051b8be9346 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { runWhenGlobalIdle } from '../../../../../base/common/async.js'; +import { runWhenGlobalIdle, ThrottledDelayer } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { isCancellationError, onUnexpectedError } from '../../../../../base/common/errors.js'; import { Event } from '../../../../../base/common/event.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Lazy } from '../../../../../base/common/lazy.js'; @@ -18,6 +19,7 @@ import { IEnvironmentService } from '../../../../../platform/environment/common/ 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 { IMeteredConnectionService } from '../../../../../platform/meteredConnection/common/meteredConnection.js'; import { ObservableMemento, observableMemento } from '../../../../../platform/observable/common/observableMemento.js'; import { asJson, IRequestService } from '../../../../../platform/request/common/request.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; @@ -315,7 +317,9 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke private readonly _trustedMarketplacesStore: ObservableMemento; private readonly _lastFetchedPluginsStore: ObservableMemento; private readonly _marketplacesWithUpdates = observableValue>('marketplacesWithUpdates', new Set()); - private _updateCheckTimer: ReturnType | undefined; + private readonly _updateCheckDelayer = this._register(new ThrottledDelayer(PLUGIN_UPDATE_CHECK_INTERVAL_MS)); + private _updateChecksInitialized = false; + private _updateCheckRunning = false; readonly onDidChangeMarketplaces: Event; @@ -335,6 +339,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke @IWorkspacePluginSettingsService private readonly _workspacePluginSettingsService: IWorkspacePluginSettingsService, @IWorkspaceTrustManagementService private readonly _workspaceTrustService: IWorkspaceTrustManagementService, @IExtensionsWorkbenchService private readonly _extensionsWorkbenchService: IExtensionsWorkbenchService, + @IMeteredConnectionService private readonly _meteredConnectionService: IMeteredConnectionService, ) { super(); @@ -404,6 +409,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke ); this._register(runWhenGlobalIdle(() => { + this._updateChecksInitialized = true; this._scheduleUpdateCheck(); this._register(Event.filter( _configurationService.onDidChangeConfiguration, @@ -411,8 +417,15 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke || e.affectsConfiguration(ChatConfiguration.ExtraMarketplaces) || e.affectsConfiguration(ChatConfiguration.StrictMarketplaces), )(() => { - this.clearUpdatesAvailable(); - this._scheduleUpdateCheck(); + this._marketplacesWithUpdates.set(new Set(), undefined); + this._scheduleUpdateCheck(0); + })); + this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { + if (isMetered) { + this._updateCheckDelayer.cancel(); + } else if (!this._updateCheckRunning && !this._updateCheckDelayer.isTriggered()) { + this._scheduleUpdateCheck(); + } })); })); @@ -429,21 +442,18 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke })); } - override dispose(): void { - if (this._updateCheckTimer !== undefined) { - clearTimeout(this._updateCheckTimer); - this._updateCheckTimer = undefined; - } - super.dispose(); - } - clearUpdatesAvailable(marketplaceIds?: ReadonlySet): void { - if (!marketplaceIds) { - this._marketplacesWithUpdates.set(new Set(), undefined); - return; - } - const remaining = new Set([...this._marketplacesWithUpdates.get()].filter(id => !marketplaceIds.has(id))); + const remaining = marketplaceIds + ? new Set([...this._marketplacesWithUpdates.get()].filter(id => !marketplaceIds.has(id))) + : new Set(); this._marketplacesWithUpdates.set(remaining, undefined); + + if (remaining.size === 0 + && this._updateChecksInitialized + && !this._updateCheckRunning + && !this._updateCheckDelayer.isTriggered()) { + this._scheduleUpdateCheck(); + } } async fetchMarketplacePlugins(token: CancellationToken, marketplaceIds?: ReadonlySet, options?: IFetchMarketplacePluginsOptions): Promise { @@ -823,16 +833,16 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } /** - * (Re-)schedules the next periodic update check. Called on - * construction and whenever the auto-update config changes. + * (Re-)schedules the next periodic update check after startup idle and + * whenever the auto-update config or metered connection state changes. */ - private _scheduleUpdateCheck(): void { - if (this._updateCheckTimer !== undefined) { - clearTimeout(this._updateCheckTimer); - this._updateCheckTimer = undefined; - } + private _scheduleUpdateCheck(delayOverride?: number): void { + this._updateCheckDelayer.cancel(); - if (!this._hasAutoUpdateEnabledMarketplace()) { + if (this._store.isDisposed + || this._meteredConnectionService.isConnectionMetered + || this._marketplacesWithUpdates.get().size > 0 + || !this._hasAutoUpdateEnabledMarketplace()) { return; } @@ -842,13 +852,29 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke 0, ); const elapsed = Date.now() - lastCheck; - const delay = Math.max(0, PLUGIN_UPDATE_CHECK_INTERVAL_MS - elapsed); + const delay = delayOverride ?? Math.max(0, PLUGIN_UPDATE_CHECK_INTERVAL_MS - elapsed); - this._updateCheckTimer = setTimeout(() => this._runUpdateCheck(), delay); + this._updateCheckDelayer.trigger(async () => { + this._updateCheckRunning = true; + try { + await this._doRunUpdateCheck(); + } finally { + this._updateCheckRunning = false; + if (!this._updateCheckDelayer.isTriggered()) { + this._scheduleUpdateCheck(PLUGIN_UPDATE_CHECK_INTERVAL_MS); + } + } + }, delay).catch(error => { + if (!isCancellationError(error)) { + onUnexpectedError(error); + } + }); } - private async _runUpdateCheck(): Promise { - this._updateCheckTimer = undefined; + private async _doRunUpdateCheck(): Promise { + if (this._meteredConnectionService.isConnectionMetered) { + return; + } try { const installed = this.installedPlugins.get(); @@ -887,11 +913,6 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke ); } catch (err) { this._logService.debug('[PluginMarketplaceService] Periodic update check failed:', err); - } finally { - // Reschedule for the next check - if (this._hasAutoUpdateEnabledMarketplace()) { - this._updateCheckTimer = setTimeout(() => this._runUpdateCheck(), PLUGIN_UPDATE_CHECK_INTERVAL_MS); - } } } diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts index c88cff13d668cd..a43f4e286ffce5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts @@ -5,26 +5,46 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IMeteredConnectionService } from '../../../../../../platform/meteredConnection/common/meteredConnection.js'; import { PluginAutoUpdate } from '../../../browser/pluginAutoUpdate.js'; import { IPluginInstallService, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../../../common/plugins/pluginInstallService.js'; import { IPluginMarketplaceService } from '../../../common/plugins/pluginMarketplaceService.js'; +class TestMeteredConnectionService extends Disposable implements IMeteredConnectionService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeIsConnectionMetered = this._register(new Emitter()); + readonly onDidChangeIsConnectionMetered = this._onDidChangeIsConnectionMetered.event; + + constructor(public isConnectionMetered: boolean) { + super(); + } + + setIsConnectionMetered(isConnectionMetered: boolean): void { + this.isConnectionMetered = isConnectionMetered; + this._onDidChangeIsConnectionMetered.fire(isConnectionMetered); + } +} + suite('PluginAutoUpdate', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); interface MockState { marketplacesWithUpdates: ReturnType>>; updateAllCalls: IUpdateAllPluginsOptions[]; - updateAllImpl: () => Promise; + updateAllImpl: (token: CancellationToken) => Promise; clearUpdatesAvailableCalls: ReadonlySet[]; } - function createContribution(stateOverrides?: Partial): { contribution: PluginAutoUpdate; state: MockState } { + function createContribution(stateOverrides?: Partial, isConnectionMetered = false): { contribution: PluginAutoUpdate; state: MockState; meteredConnectionService: TestMeteredConnectionService } { const instantiationService = store.add(new TestInstantiationService()); + const meteredConnectionService = store.add(new TestMeteredConnectionService(isConnectionMetered)); const state: MockState = { marketplacesWithUpdates: observableValue>('test.marketplacesWithUpdates', new Set()), @@ -46,14 +66,15 @@ suite('PluginAutoUpdate', () => { instantiationService.stub(IPluginInstallService, { updateAllPlugins: async (options: IUpdateAllPluginsOptions, _token: CancellationToken): Promise => { state.updateAllCalls.push(options); - return state.updateAllImpl(); + return state.updateAllImpl(_token); }, } as Partial as IPluginInstallService); instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IMeteredConnectionService, meteredConnectionService); const contribution = store.add(instantiationService.createInstance(PluginAutoUpdate)); - return { contribution, state }; + return { contribution, state, meteredConnectionService }; } /** Waits for an in-flight microtask-driven update to settle. */ @@ -80,6 +101,90 @@ suite('PluginAutoUpdate', () => { })), [{ silent: true, automatic: true, marketplaceIds: ['github:microsoft/plugins'] }]); }); + test('retains queued updates while metered and runs them when unmetered', async () => { + const { state, meteredConnectionService } = createContribution(undefined, true); + + state.marketplacesWithUpdates.set(new Set(['github:microsoft/plugins']), undefined); + await flushMicrotasks(); + assert.deepStrictEqual({ + updateAllCalls: state.updateAllCalls, + clearUpdatesAvailableCalls: state.clearUpdatesAvailableCalls, + }, { + updateAllCalls: [], + clearUpdatesAvailableCalls: [], + }); + + meteredConnectionService.setIsConnectionMetered(false); + await flushMicrotasks(); + await flushMicrotasks(); + + assert.deepStrictEqual({ + updateAllCalls: state.updateAllCalls.map(call => [...call.marketplaceIds ?? []]), + clearUpdatesAvailableCalls: state.clearUpdatesAvailableCalls.map(ids => [...ids]), + }, { + updateAllCalls: [['github:microsoft/plugins']], + clearUpdatesAvailableCalls: [['github:microsoft/plugins']], + }); + }); + + test('allows an in-flight update to finish after the connection becomes metered', async () => { + let resolveUpdate!: () => void; + const pendingUpdate = new Promise(resolve => { + resolveUpdate = () => resolve({ updatedNames: [], failedNames: [] }); + }); + const { state, meteredConnectionService } = createContribution({ + updateAllImpl: () => pendingUpdate, + }); + + state.marketplacesWithUpdates.set(new Set(['a']), undefined); + await flushMicrotasks(); + meteredConnectionService.setIsConnectionMetered(true); + resolveUpdate(); + await pendingUpdate; + await flushMicrotasks(); + + assert.deepStrictEqual({ + updateAllCalls: state.updateAllCalls.length, + clearUpdatesAvailableCalls: state.clearUpdatesAvailableCalls.length, + updateStillQueued: [...state.marketplacesWithUpdates.get()], + }, { + updateAllCalls: 1, + clearUpdatesAvailableCalls: 1, + updateStillQueued: [], + }); + + meteredConnectionService.setIsConnectionMetered(false); + await flushMicrotasks(); + assert.strictEqual(state.updateAllCalls.length, 1); + }); + + test('disposing during an in-flight update does not restart queued work', async () => { + let resolveUpdate!: () => void; + const pendingUpdate = new Promise(resolve => { + resolveUpdate = () => resolve({ updatedNames: [], failedNames: [] }); + }); + const { contribution, state } = createContribution({ + updateAllImpl: () => pendingUpdate, + }); + + state.marketplacesWithUpdates.set(new Set(['a']), undefined); + await flushMicrotasks(); + contribution.dispose(); + resolveUpdate(); + await pendingUpdate; + await flushMicrotasks(); + + assert.deepStrictEqual({ + updateAllCalls: state.updateAllCalls.length, + clearUpdatesAvailableCalls: state.clearUpdatesAvailableCalls.length, + updateStillQueued: [...state.marketplacesWithUpdates.get()], + }, { + updateAllCalls: 1, + clearUpdatesAvailableCalls: 1, + updateStillQueued: [], + }); + }); + test('queues a marketplace reported while another update is in flight', async () => { let resolveUpdate!: () => void; const pendingUpdate = new Promise(resolve => { 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 9471a8d57e00c3..d2b5cd4e7a8c83 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 @@ -4,20 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { timeout } from '../../../../../../base/common/async.js'; +import * as sinon from 'sinon'; +import { DeferredPromise, installFakeRunWhenIdle, timeout } from '../../../../../../base/common/async.js'; import { bufferToStream, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; -import { Event } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; +import { isWeb } from '../../../../../../base/common/platform.js'; import { 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'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IFileService, IFileSystemWatcher } from '../../../../../../platform/files/common/files.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IMeteredConnectionService } from '../../../../../../platform/meteredConnection/common/meteredConnection.js'; import { IRequestService } from '../../../../../../platform/request/common/request.js'; import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; @@ -28,6 +32,32 @@ import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPlug 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'; +class TestMeteredConnectionService extends Disposable implements IMeteredConnectionService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeIsConnectionMetered = this._register(new Emitter()); + readonly onDidChangeIsConnectionMetered = this._onDidChangeIsConnectionMetered.event; + + constructor(public isConnectionMetered: boolean) { + super(); + } + + setIsConnectionMetered(isConnectionMetered: boolean): void { + this.isConnectionMetered = isConnectionMetered; + this._onDidChangeIsConnectionMetered.fire(isConnectionMetered); + } +} + +const unmeteredConnectionService: IMeteredConnectionService = { + _serviceBrand: undefined, + isConnectionMetered: false, + onDidChangeIsConnectionMetered: Event.None, +}; + +function stubMeteredConnectionService(instantiationService: TestInstantiationService, service: IMeteredConnectionService = unmeteredConnectionService): void { + instantiationService.stub(IMeteredConnectionService, service); +} + suite('PluginMarketplaceService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -431,6 +461,7 @@ suite('PluginMarketplaceService - GitHub marketplace refs', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'on', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); await service.fetchMarketplacePlugins(CancellationToken.None); @@ -468,6 +499,7 @@ suite('PluginMarketplaceService - GitHub marketplace refs', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'on', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); const seeded = service.lastFetchedPlugins.get(); @@ -526,6 +558,7 @@ suite('PluginMarketplaceService - Agent Plugin direct install probes', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'off', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } @@ -589,6 +622,7 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => autoUpdate, } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } @@ -651,29 +685,36 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { const marketplaceRef = parseMarketplaceReference('microsoft/plugins')!; - function makePlugin(name: string, source: string): IMarketplacePlugin { + function makePlugin(name: string, source: string, reference = marketplaceRef): IMarketplacePlugin { return { name, description: `${name} description`, version: '1.0.0', source, sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: source } as const, - marketplace: marketplaceRef.displayLabel, - marketplaceReference: marketplaceRef, + marketplace: reference.displayLabel, + marketplaceReference: reference, marketplaceType: MarketplaceType.Copilot, }; } - function createService(): PluginMarketplaceService { + function createService(options?: { + configurationService?: TestConfigurationService; + meteredConnectionService?: IMeteredConnectionService; + pluginRepositoryService?: Partial; + }): PluginMarketplaceService { const instantiationService = store.add(new TestInstantiationService()); - instantiationService.stub(IConfigurationService, new TestConfigurationService({ + instantiationService.stub(IConfigurationService, options?.configurationService ?? new TestConfigurationService({ [ChatConfiguration.PluginMarketplaces]: ['microsoft/plugins'], [ChatConfiguration.PluginsEnabled]: true, })); instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as Partial as IEnvironmentService); instantiationService.stub(IFileService, {} as unknown as IFileService); - instantiationService.stub(IAgentPluginRepositoryService, { agentPluginsHome: URI.file('/agent-plugins') } as unknown as IAgentPluginRepositoryService); + instantiationService.stub(IAgentPluginRepositoryService, { + agentPluginsHome: URI.file('/agent-plugins'), + ...options?.pluginRepositoryService, + } as IAgentPluginRepositoryService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IRequestService, {} as unknown as IRequestService); instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); @@ -688,6 +729,7 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'on', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService, options?.meteredConnectionService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } @@ -709,6 +751,273 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { assert.strictEqual(installed[0].plugin.name, 'my-plugin'); }); + test('periodic update checking pauses while metered and resumes when unmetered', async () => { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const meteredConnectionService = store.add(new TestMeteredConnectionService(true)); + let fetchCount = 0; + const service = createService({ + meteredConnectionService, + pluginRepositoryService: { + fetchRepository: async () => { + fetchCount++; + return false; + }, + }, + }); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'), + makePlugin('my-plugin', 'my-plugin'), + ); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await timeout(0); + assert.strictEqual(fetchCount, 0); + + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + await timeout(0); + assert.strictEqual(fetchCount, 1); + }); + + test('defers an overdue check until queued updates are acknowledged', async () => { + const updateCheckInterval = 24 * 60 * 60 * 1000; + const clock = sinon.useFakeTimers({ now: updateCheckInterval + 1 }); + try { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const meteredConnectionService = store.add(new TestMeteredConnectionService(false)); + let fetchCount = 0; + const service = createService({ + meteredConnectionService, + pluginRepositoryService: { + fetchRepository: async () => ++fetchCount === 1, + }, + }); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'), + makePlugin('my-plugin', 'my-plugin'), + ); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await clock.tickAsync(0); + assert.deepStrictEqual({ + fetchCount, + marketplacesWithUpdates: [...service.marketplacesWithUpdates.get()], + }, { + fetchCount: 1, + marketplacesWithUpdates: [marketplaceRef.canonicalId], + }); + + meteredConnectionService.setIsConnectionMetered(true); + await clock.tickAsync(updateCheckInterval); + meteredConnectionService.setIsConnectionMetered(false); + await clock.tickAsync(0); + assert.strictEqual(fetchCount, 1); + + service.clearUpdatesAvailable(new Set([marketplaceRef.canonicalId])); + await clock.tickAsync(0); + assert.strictEqual(fetchCount, 2); + } finally { + clock.restore(); + } + }); + + test('unmetering before startup idle does not start an update check', async () => { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const meteredConnectionService = store.add(new TestMeteredConnectionService(true)); + let fetchCount = 0; + const service = createService({ + meteredConnectionService, + pluginRepositoryService: { + fetchRepository: async () => { + fetchCount++; + return false; + }, + }, + }); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'), + makePlugin('my-plugin', 'my-plugin'), + ); + + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + await timeout(0); + assert.strictEqual(fetchCount, 0); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await timeout(0); + await timeout(0); + assert.strictEqual(fetchCount, 1); + }); + + test('cancelling a scheduled update check does not cause an unhandled rejection', async () => { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const meteredConnectionService = store.add(new TestMeteredConnectionService(false)); + createService({ meteredConnectionService }); + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason); + const onBrowserUnhandledRejection = (event: PromiseRejectionEvent) => onUnhandledRejection(event.reason); + if (isWeb) { + globalThis.addEventListener('unhandledrejection', onBrowserUnhandledRejection); + } else { + process.on('unhandledRejection', onUnhandledRejection); + } + + try { + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + meteredConnectionService.setIsConnectionMetered(true); + await timeout(0); + + assert.deepStrictEqual(unhandledRejections, []); + } finally { + if (isWeb) { + globalThis.removeEventListener('unhandledrejection', onBrowserUnhandledRejection); + } else { + process.off('unhandledRejection', onUnhandledRejection); + } + } + }); + + test('unmetering while a check is in flight does not start a concurrent check', async () => { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const meteredConnectionService = store.add(new TestMeteredConnectionService(false)); + const firstFetch = new DeferredPromise(); + let activeFetches = 0; + let maxActiveFetches = 0; + let fetchCount = 0; + const service = createService({ + meteredConnectionService, + pluginRepositoryService: { + fetchRepository: async () => { + fetchCount++; + activeFetches++; + maxActiveFetches = Math.max(maxActiveFetches, activeFetches); + try { + return fetchCount === 1 ? await firstFetch.p : false; + } finally { + activeFetches--; + } + }, + }, + }); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'), + makePlugin('my-plugin', 'my-plugin'), + ); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await timeout(0); + meteredConnectionService.setIsConnectionMetered(true); + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + + assert.deepStrictEqual({ fetchCount, maxActiveFetches }, { fetchCount: 1, maxActiveFetches: 1 }); + + firstFetch.complete(false); + await timeout(0); + await timeout(0); + + assert.deepStrictEqual({ fetchCount, maxActiveFetches }, { fetchCount: 1, maxActiveFetches: 1 }); + }); + + test('configuration changes during a check queue one rerun without overlapping fetches', async () => { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const skippedRef = parseMarketplaceReference('microsoft/skipped')!; + const deferredRef = parseMarketplaceReference('microsoft/deferred')!; + const configurationService = new TestConfigurationService({ + [ChatConfiguration.PluginMarketplaces]: [skippedRef.canonicalId, deferredRef.canonicalId], + [ChatConfiguration.PluginsEnabled]: true, + [ChatConfiguration.StrictMarketplaces]: [{ source: 'github', repo: 'microsoft/deferred' }], + }); + const firstFetch = new DeferredPromise(); + const fetched: string[] = []; + let activeFetches = 0; + let maxActiveFetches = 0; + const service = createService({ + configurationService, + pluginRepositoryService: { + fetchRepository: async reference => { + fetched.push(reference.canonicalId); + activeFetches++; + maxActiveFetches = Math.max(maxActiveFetches, activeFetches); + try { + return fetched.length === 1 ? await firstFetch.p : false; + } finally { + activeFetches--; + } + }, + }, + }); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/skipped/plugin'), + makePlugin('skipped', 'plugin', skippedRef), + ); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/deferred/plugin'), + makePlugin('deferred', 'plugin', deferredRef), + ); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await timeout(0); + assert.deepStrictEqual(fetched, [deferredRef.canonicalId]); + + await configurationService.setUserConfiguration(ChatConfiguration.StrictMarketplaces, [ + { source: 'github', repo: 'microsoft/skipped' }, + { source: 'github', repo: 'microsoft/deferred' }, + ]); + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([ChatConfiguration.StrictMarketplaces]), + change: { keys: [ChatConfiguration.StrictMarketplaces], overrides: [] }, + affectsConfiguration: key => key === ChatConfiguration.StrictMarketplaces, + } satisfies IConfigurationChangeEvent); + await timeout(0); + assert.deepStrictEqual({ fetched, maxActiveFetches }, { fetched: [deferredRef.canonicalId], maxActiveFetches: 1 }); + + firstFetch.complete(false); + for (let i = 0; i < 5 && fetched.length < 3; i++) { + await timeout(0); + } + + assert.deepStrictEqual({ + fetched, + maxActiveFetches, + }, { + fetched: [deferredRef.canonicalId, skippedRef.canonicalId, deferredRef.canonicalId], + maxActiveFetches: 1, + }); + }); + test('removeInstalledPlugin removes plugin from installedPlugins and metadata', () => { const service = createService(); const uri = URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'); @@ -908,6 +1217,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'on', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); @@ -961,6 +1271,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'on', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } From c4f99fba345662f9b0cf5682098cccb5ce79f124 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 20 Aug 2026 16:58:17 -0700 Subject: [PATCH 06/15] Pause automatic updates on metered connections (#331701) * update: respect metered connections Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix deferred metered update handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix deferred metered update resumption Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix metered update ordering races Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/meteredConnection.ts | 5 + .../meteredConnectionService.ts | 11 +- .../meteredConnectionMainService.ts | 8 + .../meteredConnectionService.test.ts | 51 +++ .../meteredConnectionMainService.test.ts | 36 +++ .../electron-main/abstractUpdateService.ts | 156 ++++++++-- .../electron-main/updateService.win32.ts | 17 +- .../abstractUpdateService.test.ts | 292 +++++++++++++++++- .../browser/meteredConnectionStatus.ts | 2 +- .../update/browser/postUpdateWidget.ts | 6 + .../contrib/update/browser/updateTooltip.ts | 17 +- .../test/browser/updateTitleBarEntry.test.ts | 12 +- .../electron-browser/postUpdateWidget.test.ts | 95 ++++++ 13 files changed, 654 insertions(+), 54 deletions(-) create mode 100644 src/vs/platform/meteredConnection/test/electron-browser/meteredConnectionService.test.ts create mode 100644 src/vs/platform/meteredConnection/test/electron-main/meteredConnectionMainService.test.ts create mode 100644 src/vs/workbench/contrib/update/test/electron-browser/postUpdateWidget.test.ts diff --git a/src/vs/platform/meteredConnection/common/meteredConnection.ts b/src/vs/platform/meteredConnection/common/meteredConnection.ts index 2eb6796aae722d..9448920e163844 100644 --- a/src/vs/platform/meteredConnection/common/meteredConnection.ts +++ b/src/vs/platform/meteredConnection/common/meteredConnection.ts @@ -23,6 +23,11 @@ export interface IMeteredConnectionService { */ readonly isConnectionMetered: boolean; + /** + * Resolves once the initial connection state is available, when initialization is asynchronous. + */ + readonly whenConnectionStateInitialized?: Promise; + /** * Event that fires when the metered connection status changes. */ diff --git a/src/vs/platform/meteredConnection/electron-browser/meteredConnectionService.ts b/src/vs/platform/meteredConnection/electron-browser/meteredConnectionService.ts index cf0276efc2e927..728ce40afccf40 100644 --- a/src/vs/platform/meteredConnection/electron-browser/meteredConnectionService.ts +++ b/src/vs/platform/meteredConnection/electron-browser/meteredConnectionService.ts @@ -6,7 +6,8 @@ import { toDisposable } from '../../../base/common/lifecycle.js'; import { IChannel } from '../../../base/parts/ipc/common/ipc.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; -import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { SyncDescriptor } from '../../instantiation/common/descriptors.js'; +import { registerSingleton } from '../../instantiation/common/extensions.js'; import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; import { AbstractMeteredConnectionService, getIsBrowserConnectionMetered, IMeteredConnectionService, NavigatorWithConnection } from '../common/meteredConnection.js'; import { METERED_CONNECTION_CHANNEL, MeteredConnectionCommand } from '../common/meteredConnectionIpc.js'; @@ -19,15 +20,17 @@ export class NativeMeteredConnectionService extends AbstractMeteredConnectionSer private readonly _channel: IChannel; constructor( + private readonly connectionMeteredDetector: () => boolean, @IConfigurationService configurationService: IConfigurationService, @IMainProcessService mainProcessService: IMainProcessService ) { - super(configurationService, getIsBrowserConnectionMetered()); + super(configurationService, connectionMeteredDetector()); this._channel = mainProcessService.getChannel(METERED_CONNECTION_CHANNEL); + void this._channel.call(MeteredConnectionCommand.SetIsBrowserConnectionMetered, this.isBrowserConnectionMetered); const connection = (navigator as NavigatorWithConnection).connection; if (connection) { - const onChange = () => this.setIsBrowserConnectionMetered(getIsBrowserConnectionMetered()); + const onChange = () => this.setIsBrowserConnectionMetered(this.connectionMeteredDetector()); connection.addEventListener('change', onChange); this._register(toDisposable(() => connection.removeEventListener('change', onChange))); } @@ -42,4 +45,4 @@ export class NativeMeteredConnectionService extends AbstractMeteredConnectionSer } } -registerSingleton(IMeteredConnectionService, NativeMeteredConnectionService, InstantiationType.Delayed); +registerSingleton(IMeteredConnectionService, new SyncDescriptor(NativeMeteredConnectionService, [getIsBrowserConnectionMetered], false)); diff --git a/src/vs/platform/meteredConnection/electron-main/meteredConnectionMainService.ts b/src/vs/platform/meteredConnection/electron-main/meteredConnectionMainService.ts index 864e9f09693aed..c77a2fed7ef906 100644 --- a/src/vs/platform/meteredConnection/electron-main/meteredConnectionMainService.ts +++ b/src/vs/platform/meteredConnection/electron-main/meteredConnectionMainService.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { DeferredPromise } from '../../../base/common/async.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AbstractMeteredConnectionService } from '../common/meteredConnection.js'; @@ -13,6 +14,8 @@ import { AbstractMeteredConnectionService } from '../common/meteredConnection.js */ export class MeteredConnectionMainService extends AbstractMeteredConnectionService { private telemetryService: ITelemetryService | undefined; + private readonly connectionStateInitialized = new DeferredPromise(); + readonly whenConnectionStateInitialized = this.connectionStateInitialized.p; constructor(@IConfigurationService configurationService: IConfigurationService) { super(configurationService, false); @@ -22,6 +25,11 @@ export class MeteredConnectionMainService extends AbstractMeteredConnectionServi this.telemetryService = telemetryService; } + public override setIsBrowserConnectionMetered(value: boolean): void { + super.setIsBrowserConnectionMetered(value); + this.connectionStateInitialized.complete(); + } + protected override onChangeBrowserConnection() { // Fire event after sending telemetry if switching to metered since telemetry will be paused. const fireAfter = this.isBrowserConnectionMetered; diff --git a/src/vs/platform/meteredConnection/test/electron-browser/meteredConnectionService.test.ts b/src/vs/platform/meteredConnection/test/electron-browser/meteredConnectionService.test.ts new file mode 100644 index 00000000000000..36355c464b94c7 --- /dev/null +++ b/src/vs/platform/meteredConnection/test/electron-browser/meteredConnectionService.test.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../base/common/event.js'; +import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { IMainProcessService } from '../../../ipc/common/mainProcessService.js'; +import { METERED_CONNECTION_CHANNEL, MeteredConnectionCommand } from '../../common/meteredConnectionIpc.js'; +import { NativeMeteredConnectionService } from '../../electron-browser/meteredConnectionService.js'; + +class TestChannel implements IChannel { + readonly calls: { command: string; argument: unknown }[] = []; + + call(command: string, arg?: unknown, _cancellationToken?: CancellationToken): Promise { + this.calls.push({ command, argument: arg }); + return Promise.resolve(undefined as T); + } + + listen(_event: string, _arg?: unknown): Event { + return Event.None; + } +} + +suite('NativeMeteredConnectionService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('reports the initial browser connection state to the main process', () => { + const channel = new TestChannel(); + const mainProcessService = new class extends mock() { + override getChannel(channelName: string): IChannel { + assert.strictEqual(channelName, METERED_CONNECTION_CHANNEL); + return channel; + } + }; + const configurationService = new TestConfigurationService(); + store.add(configurationService.onDidChangeConfigurationEmitter); + + store.add(new NativeMeteredConnectionService(() => true, configurationService, mainProcessService)); + + assert.deepStrictEqual(channel.calls, [{ + command: MeteredConnectionCommand.SetIsBrowserConnectionMetered, + argument: true, + }]); + }); +}); diff --git a/src/vs/platform/meteredConnection/test/electron-main/meteredConnectionMainService.test.ts b/src/vs/platform/meteredConnection/test/electron-main/meteredConnectionMainService.test.ts new file mode 100644 index 00000000000000..6834a5d80f814d --- /dev/null +++ b/src/vs/platform/meteredConnection/test/electron-main/meteredConnectionMainService.test.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../base/common/async.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { MeteredConnectionMainService } from '../../electron-main/meteredConnectionMainService.js'; + +suite('MeteredConnectionMainService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('initialization waits for the initial browser connection state', async () => { + const configurationService = new TestConfigurationService(); + store.add(configurationService.onDidChangeConfigurationEmitter); + const service = store.add(new MeteredConnectionMainService(configurationService)); + let initialized = false; + void service.whenConnectionStateInitialized.then(() => initialized = true); + + await timeout(0); + assert.strictEqual(initialized, false); + + service.setIsBrowserConnectionMetered(true); + await service.whenConnectionStateInitialized; + + assert.deepStrictEqual({ + initialized, + isConnectionMetered: service.isConnectionMetered, + }, { + initialized: true, + isConnectionMetered: true, + }); + }); +}); diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 09971caf6b3e33..2749d7beb453cb 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -21,7 +21,7 @@ import { IRequestService } from '../../request/common/request.js'; import { StorageScope, StorageTarget } from '../../storage/common/storage.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { AvailableForDownload, DisablementReason, IUpdateService, State, StateType, UpdateType } from '../common/update.js'; +import { AvailableForDownload, DisablementReason, IUpdate, IUpdateService, State, StateType, UpdateType } from '../common/update.js'; const LAST_KNOWN_VERSION_STORAGE_KEY = 'abstractUpdateService/lastKnownVersion'; @@ -96,13 +96,18 @@ function isCancellableState(type: StateType): boolean { } } +interface IInternalUpdateState { + readonly state: State; + readonly deferred: boolean; +} + export abstract class AbstractUpdateService extends Disposable implements IUpdateService { declare readonly _serviceBrand: undefined; protected quality: string | undefined; - private _state: State = State.Uninitialized; + private _state: IInternalUpdateState = { state: State.Uninitialized, deferred: false }; protected _overwrite: boolean = false; private _hasCheckedForOverwriteOnQuit: boolean = false; private readonly overwriteUpdatesCheckInterval = this._register(new IntervalTimer()); @@ -121,22 +126,22 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat readonly onStateChange: Event = this._onStateChange.event; get state(): State { - return this._state; + return this._state.state; } - protected setState(state: State): void { + protected setState(state: State, options?: { deferred?: boolean }): void { if (state.type === StateType.Updating) { this.logService.trace('update#setState', state.type); } else { this.logService.info('update#setState', state.type); } - this._state = state; + this._state = { state, deferred: options?.deferred ?? false }; this._onStateChange.fire(state); // Clear transient one-time properties from Idle state after delivering the event. // This prevents new windows from seeing stale error/notAvailable messages. if (state.type === StateType.Idle && (state.error || state.notAvailable)) { - this._state = State.Idle(state.updateType); + this._state = { state: State.Idle(state.updateType), deferred: false }; } // Schedule 5-minute checks when in Ready state and overwrite is supported @@ -149,6 +154,12 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat } } + private setDeferred(deferred: boolean): void { + if (this._state.deferred !== deferred) { + this._state = { ...this._state, deferred }; + } + } + constructor( @ILifecycleMainService protected readonly lifecycleMainService: ILifecycleMainService, @IConfigurationService protected configurationService: IConfigurationService, @@ -165,6 +176,12 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat lifecycleMainService.when(LifecycleMainPhase.AfterWindowOpen) .finally(() => this.initialize()); + + this._register(this.meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { + if (!isMetered) { + this.resumeAutomaticUpdates(); + } + })); } /** @@ -192,6 +209,8 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat return; } + await this.meteredConnectionService.whenConnectionStateInitialized; + // React to runtime `update.mode`/policy changes so switching to/from `none` applies without a restart. this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('update.mode')) { @@ -225,7 +244,7 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat const reason = policyDisablesUpdates ? DisablementReason.Policy : DisablementReason.ManuallyDisabled; // Skip if already disabled for this reason, so a repeated write or policy refresh is a no-op. - if (this._state.type === StateType.Disabled && this._state.reason === reason) { + if (this.state.type === StateType.Disabled && this.state.reason === reason) { return; } @@ -242,14 +261,14 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat this.quality = quality; // Move to Idle so one-time platform init (which may resume a pending update) can act; it requires Idle. - if (this._state.type === StateType.Disabled || this._state.type === StateType.Uninitialized) { + if (this.state.type === StateType.Disabled || this.state.type === StateType.Uninitialized) { this.setState(State.Idle(this.getUpdateType())); } // One-time platform init, gated behind updates being enabled so a pending update is never resumed under `none`. if (!this._postInitialized) { - this._postInitialized = true; await this.postInitialize(); + this._postInitialized = true; } this.scheduleAccordingToMode(updateMode); @@ -263,7 +282,7 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat this.scheduler.clear(); // Show a transient Cancelling state only when there is in-flight or pending work to tear down. - if (isCancellableState(this._state.type)) { + if (isCancellableState(this.state.type)) { this.setState(State.Cancelling); } @@ -299,6 +318,16 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat return; } + if (this._state.deferred && !this.meteredConnectionService.isConnectionMetered) { + this.resumeAutomaticUpdates(); + return; + } + + if (this.state.type !== StateType.Idle) { + return; + } + this.setDeferred(false); + if (updateMode === 'start') { this.logService.info('update#ctor - startup checks only; automatic updates are disabled by user preference'); @@ -310,6 +339,41 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat } } + private resumeAutomaticUpdates(): void { + if (this._disabledPermanently || !this._postInitialized || !this.quality) { + return; + } + + const updateMode = this.configurationService.getValue<'none' | 'manual' | 'start' | 'default'>('update.mode'); + if (updateMode === 'none' || updateMode === 'manual') { + return; + } + + if (this.state.type === StateType.AvailableForDownload) { + if (this._state.deferred) { + this.resumeDeferredDownload(); + } + return; + } + + if (this.state.type === StateType.Ready) { + if (this._state.deferred) { + void this.checkForOverwriteUpdates(); + } + return; + } + + if (this.state.type !== StateType.Idle) { + return; + } + + if (updateMode === 'start' && !this._state.deferred) { + return; + } + this.setDeferred(false); + this.scheduleCheckForUpdates(0, updateMode === 'default'); + } + private async trackVersionChange(): Promise { await this.applicationStorageMainService.whenReady; @@ -408,6 +472,13 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat return; } + if (!explicit && this.meteredConnectionService.isConnectionMetered) { + this.setDeferred(true); + this.logService.info('update#checkForUpdates - skipping automatic check because connection is metered'); + return; + } + + this.setDeferred(false); this.doCheckForUpdates(explicit); } @@ -419,10 +490,12 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat } if (!explicit && this.meteredConnectionService.isConnectionMetered) { + this.setDeferred(true); this.logService.info('update#downloadUpdate - skipping download because connection is metered'); return; } + this.setDeferred(false); await this.doDownloadUpdate(this.state); } @@ -430,6 +503,20 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat // noop } + protected resumeDeferredDownload(): void { + void this.downloadUpdate(false); + } + + protected deferAutomaticDownload(update: IUpdate, explicit: boolean): boolean { + if (explicit || !this.meteredConnectionService.isConnectionMetered) { + return false; + } + + this.logService.info('update#deferAutomaticDownload - deferring download because connection is metered'); + this.setState(State.AvailableForDownload(update), { deferred: true }); + return true; + } + async applyUpdate(): Promise { this.logService.trace('update#applyUpdate, state = ', this.state.type); @@ -483,11 +570,16 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat } private async checkForOverwriteUpdates(explicit: boolean = false): Promise { - if (this._state.type !== StateType.Ready) { + if (this.state.type !== StateType.Ready) { + return false; + } + + if (this.deferOverwriteCheckIfMetered(explicit)) { return false; } - const pendingUpdateCommit = this._state.update.version; + this.setDeferred(false); + const pendingUpdateCommit = this.state.update.version; if (!pendingUpdateCommit || pendingUpdateCommit === 'unknown') { return false; @@ -495,18 +587,23 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat let isLatest: boolean | undefined; + const cts = new CancellationTokenSource(); try { - const cts = new CancellationTokenSource(); - const timeoutPromise = timeout(2000).then(() => { cts.cancel(); return undefined; }); - isLatest = await Promise.race([this.isLatestVersion(pendingUpdateCommit, cts.token), timeoutPromise]); - cts.dispose(); + const timeoutPromise = timeout(2000, cts.token).then(() => { cts.cancel(); return undefined; }); + isLatest = await Promise.race([this.doIsLatestVersion(pendingUpdateCommit, cts.token), timeoutPromise]); } catch (error) { this.logService.warn('update#checkForOverwriteUpdates(): failed to check for updates, proceeding with restart'); this.logService.warn(error); return false; + } finally { + cts.dispose(true); } - if (isLatest === false && this._state.type === StateType.Ready) { + if (isLatest === false && this.state.type === StateType.Ready) { + if (this.deferOverwriteCheckIfMetered(explicit)) { + return false; + } + this.logService.info('update#readyStateCheck: newer update available, restarting update machinery'); try { @@ -517,8 +614,12 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat return false; } + if (this.deferOverwriteCheckIfMetered(explicit)) { + return false; + } + this._overwrite = true; - this.setState(State.Overwriting(this._state.update, explicit)); + this.setState(State.Overwriting(this.state.update, explicit)); this.doCheckForUpdates(explicit, pendingUpdateCommit); return true; } @@ -526,7 +627,26 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat return false; } + private deferOverwriteCheckIfMetered(explicit: boolean): boolean { + if (explicit || !this.meteredConnectionService.isConnectionMetered) { + return false; + } + + this.setDeferred(true); + this.logService.info('update#checkForOverwriteUpdates - deferring overwrite because connection is metered'); + return true; + } + async isLatestVersion(commit?: string, token: CancellationToken = CancellationToken.None): Promise { + if (this.meteredConnectionService.isConnectionMetered) { + this.logService.info('update#isLatestVersion - skipping automatic check because connection is metered'); + return undefined; + } + + return this.doIsLatestVersion(commit, token); + } + + protected async doIsLatestVersion(commit?: string, token: CancellationToken = CancellationToken.None): Promise { if (!this.quality) { return undefined; } diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 2d3aa8bd3f88bd..7628ad1ef04078 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -272,11 +272,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return Promise.resolve(null); } - // When connection is metered and this is not an explicit check, - // show update is available but don't start downloading - if (!explicit && this.meteredConnectionService.isConnectionMetered) { - this.logService.info('update#doCheckForUpdates - update available but skipping download because connection is metered'); - this.setState(State.AvailableForDownload(update)); + if (this.deferAutomaticDownload(update, explicit)) { return Promise.resolve(null); } @@ -290,6 +286,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return Promise.resolve(updatePackagePath); } + if (this.deferAutomaticDownload(update, explicit)) { + return undefined; + } + const downloadPath = `${updatePackagePath}.tmp`; return this.requestService.request({ url: update.url, callSite: 'updateService.win32.downloadUpdate' }, token) @@ -324,7 +324,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun .then(() => updatePackagePath); }); }).then(packagePath => { - if (token.isCancellationRequested) { + if (!packagePath || token.isCancellationRequested) { return; } @@ -383,6 +383,11 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.setState(State.Idle(getUpdateType())); } + protected override resumeDeferredDownload(): void { + this.setState(State.Idle(getUpdateType())); + void this.checkForUpdates(false); + } + private async getUpdatePackagePath(version: string): Promise { const cachePath = await this.cachePath; return path.join(cachePath, `CodeSetup-${this.productService.quality}-${version}.exe`); diff --git a/src/vs/platform/update/test/electron-main/abstractUpdateService.test.ts b/src/vs/platform/update/test/electron-main/abstractUpdateService.test.ts index 2766d433ed5379..a493bdb603d777 100644 --- a/src/vs/platform/update/test/electron-main/abstractUpdateService.test.ts +++ b/src/vs/platform/update/test/electron-main/abstractUpdateService.test.ts @@ -6,7 +6,9 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; -import { Event } from '../../../../base/common/event.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IConfigurationChangeEvent, IConfigurationOverrides, IConfigurationValue } from '../../../configuration/common/configuration.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; @@ -18,13 +20,36 @@ import { IProductService } from '../../../product/common/productService.js'; import { IRequestService } from '../../../request/common/request.js'; import { IApplicationStorageMainService } from '../../../storage/electron-main/storageMainService.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; -import { DisablementReason, State, StateType } from '../../common/update.js'; +import { DisablementReason, IUpdate, State, StateType } from '../../common/update.js'; import { AbstractUpdateService, IUpdateURLOptions } from '../../electron-main/abstractUpdateService.js'; +class TestMeteredConnectionService extends Disposable implements IMeteredConnectionService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeIsConnectionMetered = this._register(new Emitter()); + readonly onDidChangeIsConnectionMetered = this._onDidChangeIsConnectionMetered.event; + + constructor( + public isConnectionMetered: boolean, + readonly whenConnectionStateInitialized?: Promise, + ) { + super(); + } + + setIsConnectionMetered(isConnectionMetered: boolean): void { + this.isConnectionMetered = isConnectionMetered; + this._onDidChangeIsConnectionMetered.fire(isConnectionMetered); + } +} + class TestUpdateService extends AbstractUpdateService { private readonly _initialized = new DeferredPromise(); get whenInitialized(): Promise { return this._initialized.p; } + private readonly _postInitializeStarted = new DeferredPromise(); + get whenPostInitializeStarted(): Promise { return this._postInitializeStarted.p; } + private _postInitializeGate: Promise | undefined; + blockPostInitialize(gate: Promise): void { this._postInitializeGate = gate; } private _checkCount = 0; get checkCount(): number { return this._checkCount; } @@ -32,12 +57,20 @@ class TestUpdateService extends AbstractUpdateService { private _cancelCount = 0; get cancelCount(): number { return this._cancelCount; } + private _downloadCount = 0; + get downloadCount(): number { return this._downloadCount; } + private _latestVersionResult: Promise | undefined; + setLatestVersionResult(result: Promise): void { this._latestVersionResult = result; } + deferDownload(update: IUpdate, explicit: boolean): boolean { + return this.deferAutomaticDownload(update, explicit); + } + /** When set, `cancelUpdate` blocks on this promise so tests can observe the transient Cancelling state. */ private _cancelGate: Promise | undefined; blockCancelUpdate(gate: Promise): void { this._cancelGate = gate; } /** Forces the service into a given state so tests can exercise cancellation from a cancellable state. */ - forceState(state: State): void { this.setState(state); } + forceState(state: State, options?: { deferred?: boolean }): void { this.setState(state, options); } feedUrl: string | undefined = 'https://update.example/feed'; @@ -57,6 +90,23 @@ class TestUpdateService extends AbstractUpdateService { this._checkCount++; } + protected override async doDownloadUpdate(): Promise { + this._downloadCount++; + } + + checkLatestVersionExplicitly(): Promise { + return this.doIsLatestVersion(); + } + + protected override doIsLatestVersion(commit?: string, token?: CancellationToken): Promise { + return this._latestVersionResult ?? super.doIsLatestVersion(commit, token); + } + + protected override async postInitialize(): Promise { + this._postInitializeStarted.complete(); + await this._postInitializeGate; + } + protected override async cancelUpdate(): Promise { this._cancelCount++; if (this._cancelGate) { @@ -91,10 +141,13 @@ suite('AbstractUpdateService', () => { } let configurationService: PolicyTestConfigurationService; + let requestCount: number; + let meteredConnectionService: TestMeteredConnectionService; - function createService(mode: string, options?: { isBuilt?: boolean; disableUpdates?: boolean; updateUrl?: string }): TestUpdateService { + function createService(mode: string, options?: { isBuilt?: boolean; disableUpdates?: boolean; updateUrl?: string; isConnectionMetered?: boolean; meteredConnectionInitialization?: Promise; postInitializeGate?: Promise; supportsUpdateOverwrite?: boolean }): TestUpdateService { configurationService = new PolicyTestConfigurationService(); configurationService.setUserConfiguration('update.mode', mode); + requestCount = 0; const lifecycleMainService = { when: () => Promise.resolve(), @@ -109,7 +162,10 @@ suite('AbstractUpdateService', () => { } as unknown as IEnvironmentMainService; const requestService = { - request: () => Promise.reject(new Error('not expected')) + request: () => { + requestCount++; + return Promise.reject(new Error('not expected')); + } } as unknown as IRequestService; const productService = { @@ -126,7 +182,7 @@ suite('AbstractUpdateService', () => { store: () => { } } as unknown as IApplicationStorageMainService; - const meteredConnectionService = { isConnectionMetered: false } as unknown as IMeteredConnectionService; + meteredConnectionService = store.add(new TestMeteredConnectionService(options?.isConnectionMetered ?? false, options?.meteredConnectionInitialization)); const service = new TestUpdateService( lifecycleMainService, @@ -138,8 +194,11 @@ suite('AbstractUpdateService', () => { NullTelemetryService, applicationStorageMainService, meteredConnectionService, - false + options?.supportsUpdateOverwrite ?? false ); + if (options?.postInitializeGate) { + service.blockPostInitialize(options.postInitializeGate); + } return store.add(service); } @@ -223,6 +282,225 @@ suite('AbstractUpdateService', () => { } }); + test('automatic scheduling waits for the initial metered connection state', async () => { + const clock = sinon.useFakeTimers(); + try { + const connectionInitialized = new DeferredPromise(); + const service = createService('default', { meteredConnectionInitialization: connectionInitialized.p }); + + await clock.tickAsync(30 * 1000); + assert.strictEqual(service.checkCount, 0); + + meteredConnectionService.setIsConnectionMetered(true); + connectionInitialized.complete(); + await service.whenInitialized; + await clock.tickAsync(30 * 1000); + assert.strictEqual(service.checkCount, 0); + + meteredConnectionService.setIsConnectionMetered(false); + await clock.tickAsync(0); + assert.strictEqual(service.checkCount, 1); + } finally { + clock.restore(); + } + }); + + test('unmetering during post-initialization does not start a check', async () => { + const clock = sinon.useFakeTimers(); + try { + const postInitializeGate = new DeferredPromise(); + const service = createService('default', { isConnectionMetered: true, postInitializeGate: postInitializeGate.p }); + await service.whenPostInitializeStarted; + + meteredConnectionService.setIsConnectionMetered(false); + await clock.tickAsync(0); + assert.strictEqual(service.checkCount, 0); + + postInitializeGate.complete(); + await service.whenInitialized; + await clock.tickAsync(0); + assert.strictEqual(service.checkCount, 0); + + await clock.tickAsync(30 * 1000); + assert.strictEqual(service.checkCount, 1); + } finally { + clock.restore(); + } + }); + + test('metered connections skip automatic update requests but allow explicit actions', async () => { + const service = createService('default', { isConnectionMetered: true }); + await service.whenInitialized; + + await service.checkForUpdates(false); + await service.isLatestVersion(); + await service.checkForUpdates(true); + await service.checkLatestVersionExplicitly(); + + service.forceState(State.AvailableForDownload({ version: '1.1.0', productVersion: '1.1.0', url: 'https://update.example/download' })); + await service.downloadUpdate(false); + await service.downloadUpdate(true); + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + + assert.deepStrictEqual({ + checkCount: service.checkCount, + downloadCount: service.downloadCount, + requestCount, + }, { + checkCount: 1, + downloadCount: 1, + requestCount: 1, + }); + }); + + test('automatic checks resume when the connection is no longer metered', async () => { + const clock = sinon.useFakeTimers(); + try { + const service = createService('start', { isConnectionMetered: true }); + await service.whenInitialized; + await clock.tickAsync(30 * 1000); + assert.strictEqual(service.checkCount, 0); + + meteredConnectionService.setIsConnectionMetered(false); + await clock.tickAsync(0); + assert.strictEqual(service.checkCount, 1); + } finally { + clock.restore(); + } + }); + + test('completed startup checks do not run again after a metered transition', async () => { + const clock = sinon.useFakeTimers(); + try { + const service = createService('start'); + await service.whenInitialized; + await clock.tickAsync(30 * 1000); + assert.strictEqual(service.checkCount, 1); + + meteredConnectionService.setIsConnectionMetered(true); + meteredConnectionService.setIsConnectionMetered(false); + await clock.tickAsync(0); + + assert.strictEqual(service.checkCount, 1); + } finally { + clock.restore(); + } + }); + + test('only resumes automatic downloads that were deferred by metering', async () => { + const service = createService('default'); + await service.whenInitialized; + service.forceState(State.AvailableForDownload({ version: '1.1.0', productVersion: '1.1.0', url: 'https://update.example/download' })); + + meteredConnectionService.setIsConnectionMetered(true); + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + const downloadsWithoutDeferredIntent = service.downloadCount; + + meteredConnectionService.setIsConnectionMetered(true); + await service.downloadUpdate(false); + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + + assert.deepStrictEqual({ + downloadsWithoutDeferredIntent, + downloadsAfterDeferredIntent: service.downloadCount, + }, { + downloadsWithoutDeferredIntent: 0, + downloadsAfterDeferredIntent: 1, + }); + }); + + test('resumes an automatic download deferred after an update check', async () => { + const service = createService('default', { isConnectionMetered: true }); + await service.whenInitialized; + service.forceState(State.AvailableForDownload({ version: '1.1.0', productVersion: '1.1.0', url: 'https://update.example/download' }), { deferred: true }); + + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + + assert.strictEqual(service.downloadCount, 1); + }); + + test('defers an automatic download when connection becomes metered during preparation', async () => { + const service = createService('default'); + await service.whenInitialized; + const update = { version: '1.1.0', productVersion: '1.1.0', url: 'https://update.example/download' }; + service.forceState(State.Downloading(update, false, false)); + + meteredConnectionService.setIsConnectionMetered(true); + const deferred = service.deferDownload(update, false); + assert.deepStrictEqual({ deferred, state: service.state.type, downloadCount: service.downloadCount }, { + deferred: true, + state: StateType.AvailableForDownload, + downloadCount: 0, + }); + + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + assert.strictEqual(service.downloadCount, 1); + }); + + test('resumes deferred work when automatic mode is re-enabled while unmetered', async () => { + const service = createService('manual', { isConnectionMetered: true }); + await service.whenInitialized; + service.forceState(State.AvailableForDownload({ version: '1.1.0', productVersion: '1.1.0', url: 'https://update.example/download' }), { deferred: true }); + + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + assert.strictEqual(service.downloadCount, 0); + + configurationService.setUserConfiguration('update.mode', 'default'); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + await timeout(0); + await timeout(0); + + assert.strictEqual(service.downloadCount, 1); + }); + + test('resumes overwrite checks that were deferred by metering', async () => { + const clock = sinon.useFakeTimers(); + try { + const service = createService('default', { isConnectionMetered: true, supportsUpdateOverwrite: true }); + await service.whenInitialized; + service.forceState(State.Ready({ version: 'pending' }, false, false)); + + await clock.tickAsync(5 * 60 * 1000); + assert.strictEqual(requestCount, 0); + + meteredConnectionService.setIsConnectionMetered(false); + await clock.tickAsync(0); + + assert.strictEqual(requestCount, 1); + } finally { + clock.restore(); + } + }); + + test('defers overwrite continuation when connection becomes metered during latest-version probe', async () => { + const clock = sinon.useFakeTimers(); + try { + const latestVersionResult = new DeferredPromise(); + const service = createService('default', { supportsUpdateOverwrite: true }); + await service.whenInitialized; + service.setLatestVersionResult(latestVersionResult.p); + service.forceState(State.Ready({ version: 'pending' }, false, false)); + + await clock.tickAsync(5 * 60 * 1000); + meteredConnectionService.setIsConnectionMetered(true); + latestVersionResult.complete(false); + await clock.tickAsync(0); + assert.deepStrictEqual({ checkCount: service.checkCount, state: service.state.type }, { checkCount: 0, state: StateType.Ready }); + + meteredConnectionService.setIsConnectionMetered(false); + await clock.tickAsync(0); + assert.deepStrictEqual({ checkCount: service.checkCount, state: service.state.type }, { checkCount: 1, state: StateType.Overwriting }); + } finally { + clock.restore(); + } + }); + test('permanent disablement ignores runtime mode changes', async () => { const service = createService('default', { isBuilt: false }); await service.whenInitialized; diff --git a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts index 677ddbc62521b2..eea62f66bfd193 100644 --- a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts +++ b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts @@ -48,7 +48,7 @@ export class MeteredConnectionStatusContribution extends Disposable implements I name: localize('status.meteredConnection', "Metered Connection"), text: '$(radio-tower)', ariaLabel: localize('status.meteredConnection.ariaLabel', "Metered Connection Enabled"), - tooltip: localize('status.meteredConnection.tooltip', "Metered connection enabled. Some automatic features like extension updates, Settings Sync, and automatic Git operations are paused to reduce data usage."), + tooltip: localize('status.meteredConnection.tooltip', "Metered connection enabled. Some background network activity, including updates, Settings Sync, inline completions, telemetry, and automatic Git operations, is paused to reduce data usage."), command: { id: 'workbench.action.configureMeteredConnection', title: localize('status.meteredConnection.configure', "Configure") diff --git a/src/vs/workbench/contrib/update/browser/postUpdateWidget.ts b/src/vs/workbench/contrib/update/browser/postUpdateWidget.ts index 6e0df0ff7d16c1..dae0b9462b679e 100644 --- a/src/vs/workbench/contrib/update/browser/postUpdateWidget.ts +++ b/src/vs/workbench/contrib/update/browser/postUpdateWidget.ts @@ -15,6 +15,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { asTextOrError, IRequestService } from '../../../../platform/request/common/request.js'; @@ -52,6 +53,7 @@ export class PostUpdateWidgetContribution extends Disposable implements IWorkben @IHoverService private readonly hoverService: IHoverService, @ILayoutService private readonly layoutService: ILayoutService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService, @IOpenerService private readonly openerService: IOpenerService, @IProductService private readonly productService: IProductService, @IRequestService private readonly requestService: IRequestService, @@ -73,6 +75,10 @@ export class PostUpdateWidgetContribution extends Disposable implements IWorkben return; } + if (this.meteredConnectionService.isConnectionMetered) { + return; + } + if (!this.detectVersionChange()) { return; } diff --git a/src/vs/workbench/contrib/update/browser/updateTooltip.ts b/src/vs/workbench/contrib/update/browser/updateTooltip.ts index e85e477beb4b81..1a69c83c3a011f 100644 --- a/src/vs/workbench/contrib/update/browser/updateTooltip.ts +++ b/src/vs/workbench/contrib/update/browser/updateTooltip.ts @@ -12,7 +12,6 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboa import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { AvailableForDownload, Disabled, DisablementReason, Downloaded, Downloading, Idle, IUpdate, Overwriting, Ready, Restarting, State, StateType, Updating } from '../../../../platform/update/common/update.js'; import { ShowCurrentReleaseNotesActionId } from '../common/update.js'; @@ -65,7 +64,6 @@ export class UpdateTooltip extends Disposable { @ICommandService private readonly commandService: ICommandService, @IConfigurationService private readonly configurationService: IConfigurationService, @IHoverService private readonly hoverService: IHoverService, - @IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService, @IProductService private readonly productService: IProductService, ) { super(); @@ -275,8 +273,9 @@ export class UpdateTooltip extends Disposable { return; } + const updateMode = this.configurationService.getValue('update.mode'); this.renderTitleAndInfo(localize('updateTooltip.upToDateTitle', "Up to Date")); - switch (this.configurationService.getValue('update.mode')) { + switch (updateMode) { case 'none': this.renderMessage(localize('updateTooltip.autoUpdateNone', "Automatic updates are disabled."), Codicon.warning); break; @@ -287,15 +286,9 @@ export class UpdateTooltip extends Disposable { this.renderMessage(localize('updateTooltip.autoUpdateStart', "Updates will be applied on restart.")); break; case 'default': - if (this.meteredConnectionService.isConnectionMetered) { - this.renderMessage( - localize('updateTooltip.meteredConnectionMessage', "Automatic updates are paused because the network connection is metered."), - Codicon.radioTower); - } else { - this.renderMessage( - localize('updateTooltip.autoUpdateDefault', "Automatic updates are enabled. Happy Coding!"), - Codicon.smiley); - } + this.renderMessage( + localize('updateTooltip.autoUpdateDefault', "Automatic updates are enabled. Happy Coding!"), + Codicon.smiley); break; } } diff --git a/src/vs/workbench/contrib/update/test/browser/updateTitleBarEntry.test.ts b/src/vs/workbench/contrib/update/test/browser/updateTitleBarEntry.test.ts index 4c4a8d0c3ae542..b5ee28008af27d 100644 --- a/src/vs/workbench/contrib/update/test/browser/updateTitleBarEntry.test.ts +++ b/src/vs/workbench/contrib/update/test/browser/updateTitleBarEntry.test.ts @@ -18,7 +18,6 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { IMeteredConnectionService } from '../../../../../platform/meteredConnection/common/meteredConnection.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; @@ -145,23 +144,24 @@ suite('UpdateGlobalActivityBadgeVisibleContext', () => { suite('UpdateTooltip', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - test('removes hidden actions from the tab order', () => { + function createTooltip(): UpdateTooltip { const configurationService = new TestConfigurationService({ 'update.mode': 'default' }); store.add(configurationService.onDidChangeConfigurationEmitter); - const tooltip = store.add(new UpdateTooltip( + return store.add(new UpdateTooltip( new class extends mock() { }, store.add(new TestCommandService()), configurationService, new TestHoverService(), - new class extends mock() { - override readonly isConnectionMetered = false; - }, new class extends mock() { override readonly nameLong = 'Code - OSS Dev'; override readonly version = '1.134.0'; override readonly commit = 'current'; }, )); + } + + test('removes hidden actions from the tab order', () => { + const tooltip = createTooltip(); tooltip.renderState(State.Ready({ version: 'next', productVersion: '1.135.0' }, false, false)); diff --git a/src/vs/workbench/contrib/update/test/electron-browser/postUpdateWidget.test.ts b/src/vs/workbench/contrib/update/test/electron-browser/postUpdateWidget.test.ts new file mode 100644 index 00000000000000..e29834c8f9d18b --- /dev/null +++ b/src/vs/workbench/contrib/update/test/electron-browser/postUpdateWidget.test.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; +import { IRequestContext } from '../../../../../base/parts/request/common/request.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; +import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IMeteredConnectionService } from '../../../../../platform/meteredConnection/common/meteredConnection.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IRequestService } from '../../../../../platform/request/common/request.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IHostService } from '../../../../services/host/browser/host.js'; +import { PostUpdateWidgetContribution } from '../../browser/postUpdateWidget.js'; + +class TestRequestService extends mock() { + requestCount = 0; + + override async request(): Promise { + this.requestCount++; + return { + res: { statusCode: 200, headers: {} }, + stream: bufferToStream(VSBuffer.fromString('')), + }; + } +} + +suite('PostUpdateWidgetContribution (Electron)', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createContribution(isConnectionMetered: boolean): TestRequestService { + const requestService = new TestRequestService(); + const configurationService = new TestConfigurationService(); + store.add(configurationService.onDidChangeConfigurationEmitter); + store.add(new PostUpdateWidgetContribution( + new class extends mock() { }, + configurationService, + new class extends mock() { + override hadLastFocus(): Promise { + return Promise.resolve(true); + } + }, + new class extends mock() { }, + new class extends mock() { }, + new class extends mock() { }, + new class extends mock() { + override readonly isConnectionMetered = isConnectionMetered; + }, + new class extends mock() { }, + new class extends mock() { + override readonly version = '1.135.0'; + override readonly commit = 'current'; + }, + requestService, + new class extends mock() { + override getObject(): T | undefined { + return { version: '1.134.0', commit: 'previous', timestamp: 0 } as T; + } + override store(): void { } + }, + new class extends mock() { }, + )); + return requestService; + } + + test('requests update info automatically after a version change when unmetered', async () => { + const requestService = createContribution(false); + + await timeout(0); + + assert.strictEqual(requestService.requestCount, 1); + }); + + test('skips the automatic request while metered but preserves the explicit command', async () => { + const requestService = createContribution(true); + + await timeout(0); + assert.strictEqual(requestService.requestCount, 0); + + const command = CommandsRegistry.getCommand('_update.showUpdateInfo'); + assert.ok(command); + await command.handler(undefined as never); + assert.strictEqual(requestService.requestCount, 1); + }); +}); From 33e1911ac9cf64cf9f12f5c286808cc2f0882286 Mon Sep 17 00:00:00 2001 From: roblourens Date: Thu, 20 Aug 2026 16:59:14 -0700 Subject: [PATCH 07/15] Refactor AgentService instantiation (#331861) * Refactor AgentService instantiation Create an agent-host application DI scope, construct AgentService through it, and remove child-to-parent service re-exports. Update tests to use the production construction path. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify Agent Host DI scope names Use explicit bootstrap and application names for service collections and instantiation services. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Share Agent Host service initialization Use one strict DI scope and centralize common base and provider service setup for both Agent Host entry points. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Streamline Agent Host runtime creation Expose one runtime factory that owns common file, session, DI, AgentService, diagnostics, and optional provider infrastructure initialization. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move AgentService composition to runtime Construct and register the AgentService core and collaborator graph outside AgentService, use one guarded initialization step for genuine back-references, and replace the test-only clock injection with virtual timers. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use one complete Agent Host runtime graph Remove optional provider-infrastructure setup, make BYOK policy explicit, and defer Claude SDK environment mutation until first use. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Agent Host integration fixtures Keep mock-provider configuration distinct from host-owned worktree settings and use an existing workspace for permission containment tests. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Restore Claude SDK initialization behavior Keep the AgentService DI refactor focused by leaving the existing Claude SDK environment setup unchanged. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostBootstrap.ts | 173 +++++- .../platform/agentHost/node/agentHostMain.ts | 176 +----- .../agentHost/node/agentHostServerMain.ts | 188 +------ .../agentHost/node/agentMergeController.ts | 2 +- .../platform/agentHost/node/agentService.ts | 508 ++++++++---------- .../agentHost/node/agentServiceComposition.ts | 237 ++++++++ .../test/node/agentHostBootstrap.test.ts | 38 +- .../agentHost/test/node/agentService.test.ts | 469 ++++++++-------- .../test/node/agentServiceTestUtils.ts | 83 +++ .../test/node/agentSideEffects.test.ts | 8 +- .../agentHost/test/node/claudeAgent.test.ts | 4 +- .../platform/agentHost/test/node/mockAgent.ts | 37 +- .../protocol/sessionConfig.integrationTest.ts | 67 ++- .../protocol/toolApproval.integrationTest.ts | 7 +- 14 files changed, 1119 insertions(+), 878 deletions(-) create mode 100644 src/vs/platform/agentHost/node/agentServiceComposition.ts create mode 100644 src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index 2e76e8adcc16c0..a90826809bd493 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -4,17 +4,89 @@ *--------------------------------------------------------------------------------------------*/ import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { Event } from '../../../base/common/event.js'; +import { joinPath } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { Schemas } from '../../../base/common/network.js'; +import { INativeEnvironmentService } from '../../environment/common/environment.js'; +import { IFileService } from '../../files/common/files.js'; +import { FileService } from '../../files/common/fileService.js'; +import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; -import { ILogService } from '../../log/common/log.js'; +import { ILoggerService, ILogService } from '../../log/common/log.js'; +import { IProductService } from '../../product/common/productService.js'; import { IRequestService } from '../../request/common/request.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { ISandboxHelperService } from '../../sandbox/common/sandboxHelperService.js'; +import { SandboxHelperService } from '../../sandbox/node/sandboxHelper.js'; +import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../sandbox/common/terminalSandboxMxcRuntime.js'; +import { IAgentPluginManager } from '../common/agentPluginManager.js'; +import { IDiffComputeService } from '../common/diffComputeService.js'; +import { IAgentEditAttributionService } from '../common/fileEditAttribution.js'; +import { IAgentHostGitService } from '../common/agentHostGitService.js'; +import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; +import { AgentHostGitService } from './agentHostGitService.js'; +import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProxyResolver.js'; import { AgentHostRequestService } from './agentHostRequestService.js'; +import { createAgentHostTelemetryService, IAgentHostTelemetryService } from './agentHostTelemetryService.js'; +import { AgentService, IAgentServiceOptions } from './agentService.js'; +import { createAgentService } from './agentServiceComposition.js'; +import { INetworkDiagnosticsService, NetworkDiagnosticsService } from './networkDiagnosticsService.js'; +import { AgentPluginManager } from './agentPluginManager.js'; +import { NodeWorkerDiffComputeService } from './diffComputeService.js'; +import { AgentEditAttributionService } from './shared/agentEditAttributionService.js'; +import { EditArcReporterService, IEditArcReporterService } from './shared/editArcReporter.js'; +import { EditSurvivalReporterFactory, IEditSurvivalReporterFactory } from './shared/editSurvivalReporter.js'; +import { IAgentHostWorktreeIsolation, WorktreeIsolation } from './shared/worktreeIsolation.js'; +import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; +import { IClaudeAgentSdkService, ClaudeAgentSdkService } from './claude/claudeAgentSdkService.js'; +import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; +import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; +import { IByokLmBridgeRegistry, NullByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; +import { ByokLmProxyService, IByokLmProxyService, NullByokLmProxyService } from './copilot/byokLmProxyService.js'; +import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js'; +import { SessionDataService } from './sessionDataService.js'; +import { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; +import { AgentHostLaunchKind } from '../common/agentHostTelemetry.js'; export interface IAgentHostNetworkServices { readonly proxyResolver: IAgentHostProxyResolver; readonly requestService: IRequestService; } +export interface ICreateAgentHostRuntimeOptions { + readonly environmentService: INativeEnvironmentService; + readonly productService: IProductService; + readonly logService: ILogService; + readonly loggerService: ILoggerService | undefined; + readonly disposables: DisposableStore; + readonly disableTelemetry?: boolean; + readonly transientProxyConfiguration: boolean; + readonly hostLaunchKind: AgentHostLaunchKind; + readonly providerConfigurations: readonly IAgentCustomizationSettingsRegistration[]; + /** + * The utility-process host has a renderer bridge; standalone hosts use the + * unavailable variant but still register the same complete service graph. + */ + readonly byok: { readonly kind: 'renderer'; readonly bridgeRegistry: IByokLmBridgeRegistry } | { readonly kind: 'unavailable' }; +} + +export interface IAgentHostRuntime { + readonly instantiationService: IInstantiationService; + readonly agentService: AgentService; + readonly fileService: IFileService; + readonly sessionDataService: ISessionDataService; + readonly proxyResolver: IAgentHostProxyResolver; + readonly telemetryService: IAgentHostTelemetryService; + readonly agentSdkDownloader: AgentSdkDownloader; + readonly sdkDownloadProgress: Event; +} + /** * Register `IAgentHostProxyResolver` and `IRequestService` into the agent host's * DI container — the services that `IAgentSdkDownloader` (and proxy-aware @@ -28,13 +100,106 @@ export interface IAgentHostNetworkServices { * configuration service. */ export function registerAgentHostNetworkServices( - diServices: ServiceCollection, + services: ServiceCollection, logService: ILogService, disposables: DisposableStore, ): IAgentHostNetworkServices { const proxyResolver = disposables.add(new AgentHostProxyResolver(logService)); - diServices.set(IAgentHostProxyResolver, proxyResolver); + services.set(IAgentHostProxyResolver, proxyResolver); const requestService = disposables.add(new AgentHostRequestService(logService, proxyResolver)); - diServices.set(IRequestService, requestService); + services.set(IRequestService, requestService); return { proxyResolver, requestService }; } + +export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOptions): Promise { + const { environmentService, productService, logService, loggerService, disposables } = options; + const fileService = disposables.add(new FileService(logService)); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); + disposables.add(registerPendingEditContentProvider(fileService)); + const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); + const services = new ServiceCollection( + [INativeEnvironmentService, environmentService], + [ILogService, logService], + [IFileService, fileService], + [ISessionDataService, sessionDataService], + [IProductService, productService], + ); + const networkServices = registerAgentHostNetworkServices(services, logService, disposables); + const proxyResolver = networkServices.proxyResolver; + const fetchFn = proxyResolver.fetch.bind(proxyResolver); + const telemetryService = await createAgentHostTelemetryService({ + environmentService, + productService, + fileService, + loggerService, + logService, + disposables, + disableTelemetry: options.disableTelemetry, + fetchFn, + requestService: networkServices.requestService, + }); + services.set(ITelemetryService, telemetryService); + const instantiationService = new InstantiationService(services, /*strict*/ true); + let agentService: AgentService | undefined; + try { + const fileMonitorService = disposables.add(instantiationService.createInstance(AgentHostFileMonitorService)); + services.set(IAgentHostFileMonitorService, fileMonitorService); + services.set(IWindowsMxcTerminalSandboxRuntime, instantiationService.createInstance(WindowsMxcTerminalSandboxRuntime)); + services.set(ISandboxHelperService, new SandboxHelperService()); + services.set(IAgentHostGitService, instantiationService.createInstance(AgentHostGitService)); + const agentServiceOptions: IAgentServiceOptions = { + rootConfigResource: joinPath(environmentService.appSettingsHome, 'globalStorage', 'agent-host-config.json'), + providerConfigurations: options.providerConfigurations, + hostLaunchKind: options.hostLaunchKind, + storageResource: joinPath(environmentService.appSettingsHome, 'globalStorage', 'agent-host-storage.json'), + debugLogsEnvironment: { + logsHome: environmentService.logsHome, + tmpDir: environmentService.tmpDir, + }, + }; + agentService = createAgentService(agentServiceOptions, services, instantiationService, fetchFn, logService, productService); + proxyResolver.bindConfigurationService(agentService.configurationService, options.transientProxyConfiguration); + const networkDiagnosticsService = instantiationService.createInstance(NetworkDiagnosticsService); + services.set(INetworkDiagnosticsService, networkDiagnosticsService); + agentService.setNetworkDiagnosticsService(networkDiagnosticsService); + services.set(IAgentPluginManager, new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService)); + services.set(IDiffComputeService, disposables.add(instantiationService.createInstance(NodeWorkerDiffComputeService))); + const editAttributionService = disposables.add(instantiationService.createInstance(AgentEditAttributionService, undefined, undefined)); + services.set(IAgentEditAttributionService, editAttributionService); + agentService.setEditAttributionService(editAttributionService); + services.set(IEditSurvivalReporterFactory, instantiationService.createInstance(EditSurvivalReporterFactory)); + services.set(IEditArcReporterService, disposables.add(instantiationService.createInstance(EditArcReporterService, undefined))); + + const worktreeIsolation = disposables.add(instantiationService.createInstance(WorktreeIsolation, undefined)); + services.set(IAgentHostWorktreeIsolation, worktreeIsolation); + agentService.setWorktreeIsolation(worktreeIsolation); + + const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); + services.set(IAgentSdkDownloader, agentSdkDownloader); + services.set(IClaudeProxyService, disposables.add(instantiationService.createInstance(ClaudeProxyService))); + services.set(IClaudeAgentSdkService, instantiationService.createInstance(ClaudeAgentSdkService)); + services.set(ICodexProxyService, disposables.add(instantiationService.createInstance(CodexProxyService))); + services.set(IAgentHostOTelService, disposables.add(instantiationService.createInstance(AgentHostOTelService, fetchFn))); + const byokBridgeRegistry = options.byok.kind === 'renderer' ? options.byok.bridgeRegistry : new NullByokLmBridgeRegistry(); + services.set(IByokLmBridgeRegistry, byokBridgeRegistry); + const byokLmProxyService: IByokLmProxyService = options.byok.kind === 'renderer' + ? disposables.add(instantiationService.createInstance(ByokLmProxyService)) + : new NullByokLmProxyService(); + services.set(IByokLmProxyService, byokLmProxyService); + + return { + instantiationService, + agentService, + fileService, + sessionDataService, + proxyResolver, + telemetryService, + agentSdkDownloader, + sdkDownloadProgress: agentSdkDownloader.onDidDownloadProgress, + }; + } catch (error) { + agentService?.dispose(); + instantiationService.dispose(); + throw error; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index ee8737a8590406..af19c42c4dcfd5 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -10,50 +10,29 @@ import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; import { Emitter, type Event } from '../../../base/common/event.js'; import { DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; -import { joinPath } from '../../../base/common/resources.js'; import { isWindows } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as os from 'os'; import * as inspector from 'inspector'; -import { AgentHostClaudeAgentEnabledEnvVar, AgentHostCodexAgentEnabledEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService, isAgentEnabled } from '../common/agentService.js'; +import { AgentHostClaudeAgentEnabledEnvVar, AgentHostCodexAgentEnabledEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IConnectionTrackerService, isAgentEnabled } from '../common/agentService.js'; import { AgentHostCodexEnabledConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentModelRefreshScheduler, MODEL_REFRESH_INTERVAL_MS } from './agentModelRefreshScheduler.js'; import { AgentService } from './agentService.js'; -import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; -import { IAgentHostStateManager } from './agentHostStateManager.js'; -import { IAgentHostPromptCache } from './agentHostPromptCache.js'; -import { IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; -import { IAgentConfigurationService } from './agentConfigurationService.js'; -import { IAgentHostStorageService } from './agentHostStorageService.js'; -import { IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; -import { IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; -import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; -import { IAgentHostCompletions } from './agentHostCompletions.js'; -import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; -import { IAgentHostWorktreeIsolation, WorktreeIsolation } from './shared/worktreeIsolation.js'; -import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; -import { ClaudeAgentSdkService, ClaudeSdkPackage, IClaudeAgentSdkService } from './claude/claudeAgentSdkService.js'; -import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; +import { ClaudeSdkPackage } from './claude/claudeAgentSdkService.js'; import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { createCodexProviderConfiguration } from './codex/codexProviderConfiguration.js'; -import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; -import { ByokLmProxyService, IByokLmProxyService } from './copilot/byokLmProxyService.js'; -import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; +import { ByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; import { IAgentHostProxyResolver } from './agentHostProxyResolver.js'; -import { INetworkDiagnosticsService, NetworkDiagnosticsService } from './networkDiagnosticsService.js'; -import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; -import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; -import { AgentHostOTelService } from './otel/agentHostOTelService.js'; +import { type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; import { AgentHostClientConnectionTelemetryTracker } from './agentHostClientConnectionTelemetry.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; import { MessagePortProtocolServer } from './messagePortProtocolServer.js'; import { cleanupLocalAgentHostEndpointMetadataSync, cleanupLocalAgentHostEndpointSocketSync, createLocalAgentHostEndpointMetadata, prepareLocalAgentHostEndpointMetadataDirectory, prepareLocalAgentHostEndpointSocketDirectory, publishLocalAgentHostEndpointMetadata, type ILocalAgentHostEndpointMetadata } from './localAgentHostMetadata.js'; import { AgentHostManagementService } from './agentHostManagementService.js'; -import { INativeEnvironmentService } from '../../environment/common/environment.js'; import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; import { parseArgs, OPTIONS } from '../../environment/node/argv.js'; import { getLogLevel, ILogService, isDevConsoleLogForwardingEnabled, registerDevConsoleLogForwarder } from '../../log/common/log.js'; @@ -65,40 +44,15 @@ import { DefaultURITransformer } from '../../../base/common/uriIpc.js'; import product from '../../product/common/product.js'; import { IProductService } from '../../product/common/productService.js'; import { localize } from '../../../nls.js'; -import { FileService } from '../../files/common/fileService.js'; import { IFileService } from '../../files/common/files.js'; -import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js'; -import { Schemas } from '../../../base/common/network.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { InstantiationService } from '../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; -import { registerAgentHostNetworkServices } from './agentHostBootstrap.js'; +import { createAgentHostRuntime } from './agentHostBootstrap.js'; import { BANG_COMMAND_PREFIX } from './agentHostBangCommand.js'; -import { SessionDataService } from './sessionDataService.js'; -import { ISessionDataService } from '../common/sessionDataService.js'; -import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../sandbox/common/terminalSandboxMxcRuntime.js'; -import { ISandboxHelperService } from '../../sandbox/common/sandboxHelperService.js'; -import { SandboxHelperService } from '../../sandbox/node/sandboxHelper.js'; -import { IDiffComputeService } from '../common/diffComputeService.js'; -import { IAgentEditAttributionService } from '../common/fileEditAttribution.js'; -import { NodeWorkerDiffComputeService } from './diffComputeService.js'; -import { AgentEditAttributionService } from './shared/agentEditAttributionService.js'; -import { IEditSurvivalReporterFactory, EditSurvivalReporterFactory } from './shared/editSurvivalReporter.js'; -import { EditArcReporterService, IEditArcReporterService } from './shared/editArcReporter.js'; import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, createAgentHostClientByokLmConnection } from '../common/agentHostClientByokLmChannel.js'; import { AGENT_HOST_CLIENT_PROXY_CHANNEL, createAgentHostClientProxyConnection } from '../common/agentHostClientProxyChannel.js'; -import { IAgentPluginManager } from '../common/agentPluginManager.js'; -import { AgentPluginManager } from './agentPluginManager.js'; -import { AgentHostGitService } from './agentHostGitService.js'; -import { IAgentHostGitService } from '../common/agentHostGitService.js'; -import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; -import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; -import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js'; import { join } from '../../../base/common/path.js'; -import { createAgentHostTelemetryService } from './agentHostTelemetryService.js'; -import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import ErrorTelemetry from '../../telemetry/node/errorTelemetry.js'; import { AgentHostLaunchKindEnvVar, readAgentHostLaunchKind, type AgentHostLaunchKind } from '../common/agentHostTelemetry.js'; @@ -146,115 +100,37 @@ async function startAgentHost(): Promise { } logService.info('Agent Host process started successfully'); - // File service - const fileService = disposables.add(new FileService(logService)); - disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); - // In-memory filesystem backing transient file-edit previews shown during - // tool-call confirmations. - disposables.add(registerPendingEditContentProvider(fileService)); - - // Session data service - const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); - const rootConfigResource = joinPath(environmentService.appSettingsHome, 'globalStorage', 'agent-host-config.json'); - const storageResource = joinPath(environmentService.appSettingsHome, 'globalStorage', 'agent-host-storage.json'); - // Create the real service implementation that lives in this process let agentService: AgentService; - let instantiationService: IInstantiationService; + let instantiationService!: IInstantiationService; + let fileService!: IFileService; // Hoisted out of the `try` below so the protocol handlers (constructed // after the block) can forward agent-SDK download progress to clients. let sdkDownloadProgress: Event | undefined; let byokLmBridgeRegistry: ByokLmBridgeRegistry; - let proxyResolver: IAgentHostProxyResolver | undefined; + let proxyResolver!: IAgentHostProxyResolver; const hostLaunchKind = readAgentHostLaunchKind(process.env[AgentHostLaunchKindEnvVar]); const connectionTelemetryTracker = disposables.add(new AgentHostClientConnectionTelemetryTracker()); try { - // Build the process DI container and network stack before telemetry so every - // outbound fetch, including restricted telemetry, uses the same proxy resolver. - const diServices = new ServiceCollection(); - diServices.set(INativeEnvironmentService, environmentService); - diServices.set(ILogService, logService); - diServices.set(IFileService, fileService); - diServices.set(ISessionDataService, sessionDataService); - diServices.set(IProductService, productService); - const networkServices = registerAgentHostNetworkServices(diServices, logService, disposables); - proxyResolver = networkServices.proxyResolver; - const fetchFn = proxyResolver.fetch.bind(proxyResolver); - const telemetryService = await createAgentHostTelemetryService({ environmentService, productService, fileService, loggerService, logService, disposables, fetchFn, requestService: networkServices.requestService }); - errorTelemetry.value = new ErrorTelemetry(telemetryService); - diServices.set(ITelemetryService, telemetryService); - instantiationService = new InstantiationService(diServices); - const fileMonitorService = disposables.add(instantiationService.createInstance(AgentHostFileMonitorService)); - diServices.set(IAgentHostFileMonitorService, fileMonitorService); - diServices.set(IWindowsMxcTerminalSandboxRuntime, instantiationService.createInstance(WindowsMxcTerminalSandboxRuntime)); - diServices.set(ISandboxHelperService, new SandboxHelperService()); - const gitService = instantiationService.createInstance(AgentHostGitService); - diServices.set(IAgentHostGitService, gitService); - // Register the agent SDK downloader BEFORE any service that injects it - // (ClaudeAgentSdkService and CodexAgent below). The downloader resolves - // dev-override env var → on-disk cache → product.agentSdks download. - const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); - diServices.set(IAgentSdkDownloader, agentSdkDownloader); - sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress; - const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); - diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); - // BYOK infrastructure is always wired; synchronized root config gates model - // publication and per-session provider configuration. byokLmBridgeRegistry = new ByokLmBridgeRegistry(); - diServices.set(IByokLmBridgeRegistry, byokLmBridgeRegistry); - const byokLmProxyService = disposables.add(instantiationService.createInstance(ByokLmProxyService)); - diServices.set(IByokLmProxyService, byokLmProxyService); - const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService, fetchFn)); - diServices.set(IAgentHostOTelService, agentHostOTelService); - agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)], hostLaunchKind, storageResource, undefined, undefined, { - logsHome: environmentService.logsHome, - tmpDir: environmentService.tmpDir, + const runtime = await createAgentHostRuntime({ + environmentService, + productService, + logService, + loggerService, + disposables, + transientProxyConfiguration: true, + hostLaunchKind, + providerConfigurations: [createCodexProviderConfiguration(environmentService.userHome)], + byok: { kind: 'renderer', bridgeRegistry: byokLmBridgeRegistry }, }); - diServices.set(IAgentService, agentService); - diServices.set(IAgentHostAuthenticationService, agentService.authenticationService); - diServices.set(IAgentHostStateManager, agentService.stateManager); - // Narrow host seams providers consume instead of the whole state manager. - diServices.set(IAgentHostPromptCache, agentService.promptCache); - diServices.set(IAgentHostSessionTitleSignal, agentService.sessionTitleSignal); - diServices.set(IAgentConfigurationService, agentService.configurationService); - proxyResolver.bindConfigurationService(agentService.configurationService, true); - const networkDiagnosticsService = instantiationService.createInstance(NetworkDiagnosticsService); - diServices.set(INetworkDiagnosticsService, networkDiagnosticsService); - agentService.setNetworkDiagnosticsService(networkDiagnosticsService); - const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); - diServices.set(IAgentPluginManager, pluginManager); - const diffComputeService = disposables.add(new NodeWorkerDiffComputeService(logService)); - diServices.set(IDiffComputeService, diffComputeService); - const editAttributionService = disposables.add(instantiationService.createInstance(AgentEditAttributionService, undefined, undefined)); - diServices.set(IAgentEditAttributionService, editAttributionService); - agentService.setEditAttributionService(editAttributionService); - diServices.set(IEditSurvivalReporterFactory, instantiationService.createInstance(EditSurvivalReporterFactory)); - - diServices.set(IAgentHostTerminalManager, agentService.terminalManager); - diServices.set(IAgentHostStorageService, agentService.storageService); - diServices.set(IAgentHostCustomizationEnablementService, agentService.customizationEnablementService); - diServices.set(IAgentHostManagedSettingsService, agentService.managedSettingsService); - const editArcReporterService = disposables.add(instantiationService.createInstance(EditArcReporterService, undefined)); - diServices.set(IEditArcReporterService, editArcReporterService); - diServices.set(IAgentHostGitHubEndpointService, agentService.gitHubEndpointService); - diServices.set(IAgentHostCompletions, agentService.completionsService); - diServices.set(IAgentHostCheckpointService, agentService.checkpointService); - - // CopilotApiService and the proxies that consume it are created AFTER the - // GitHub endpoint service is re-exported (above) so CAPI endpoint discovery - // can target a GitHub Enterprise host. Matches agentHostServerMain ordering. - const copilotApiService = instantiationService.createInstance(CopilotApiService, fetchFn); - diServices.set(ICopilotApiService, copilotApiService); - // Host-owned worktree isolation controller: a single instance drives folder - // / worktree isolation for every agent, so providers stay unaware of it. It - // owns its branch-name generator, created from ICopilotApiService. - const worktreeIsolation = disposables.add(instantiationService.createInstance(WorktreeIsolation, undefined)); - diServices.set(IAgentHostWorktreeIsolation, worktreeIsolation); - agentService.setWorktreeIsolation(worktreeIsolation); - const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); - diServices.set(IClaudeProxyService, claudeProxyService); - const codexProxyService = disposables.add(instantiationService.createInstance(CodexProxyService)); - diServices.set(ICodexProxyService, codexProxyService); + agentService = runtime.agentService; + instantiationService = runtime.instantiationService; + fileService = runtime.fileService; + proxyResolver = runtime.proxyResolver; + errorTelemetry.value = new ErrorTelemetry(runtime.telemetryService); + const agentSdkDownloader = runtime.agentSdkDownloader; + sdkDownloadProgress = runtime.sdkDownloadProgress; agentService.registerProvider(instantiationService.createInstance(CopilotAgent)); // Claude and Codex providers are gated on two things: // 1. The user-facing enable toggle (`chat.agentHost.Agent.enabled`, @@ -293,6 +169,7 @@ async function startAgentHost(): Promise { disposables.add(agentConfigurationService.onDidRootConfigChange(registerCodexIfEnabled)); } } catch (err) { + instantiationService?.dispose(); logService.error('Failed to create AgentService', err); throw err; } @@ -588,6 +465,7 @@ async function startAgentHost(): Promise { agentService.dispose(); logService.dispose(); disposables.dispose(); + instantiationService.dispose(); }); } diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 9d360559a41cc4..3162e25175def1 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -18,12 +18,10 @@ import * as os from 'os'; import type { Event } from '../../../base/common/event.js'; import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { raceTimeout } from '../../../base/common/async.js'; -import { joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { localize } from '../../../nls.js'; import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; -import { INativeEnvironmentService } from '../../environment/common/environment.js'; import { parseArgs, OPTIONS } from '../../environment/node/argv.js'; import { getLogLevel, ILogService } from '../../log/common/log.js'; import { LogService } from '../../log/common/logService.js'; @@ -31,70 +29,23 @@ import { LoggerService } from '../../log/node/loggerService.js'; import { OtlpEmitterLogger, OtlpLogEmitter } from '../common/otlp/otlpLogEmitter.js'; import product from '../../product/common/product.js'; import { IProductService } from '../../product/common/productService.js'; -import { InstantiationService } from '../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; -import { registerAgentHostNetworkServices } from './agentHostBootstrap.js'; +import { createAgentHostRuntime } from './agentHostBootstrap.js'; import { BANG_COMMAND_PREFIX } from './agentHostBangCommand.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; -import { INetworkDiagnosticsService, NetworkDiagnosticsService } from './networkDiagnosticsService.js'; -import { IByokLmBridgeRegistry, NullByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; -import { IByokLmProxyService, NullByokLmProxyService } from './copilot/byokLmProxyService.js'; -import { IAgentHostWorktreeIsolation, WorktreeIsolation } from './shared/worktreeIsolation.js'; -import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; -import { ClaudeAgentSdkService, ClaudeSdkPackage, IClaudeAgentSdkService } from './claude/claudeAgentSdkService.js'; -import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; +import { ClaudeSdkPackage } from './claude/claudeAgentSdkService.js'; import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { createCodexProviderConfiguration } from './codex/codexProviderConfiguration.js'; -import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; -import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; -import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; -import { AgentHostOTelService } from './otel/agentHostOTelService.js'; +import { type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { AgentHostCodexEnabledConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentModelRefreshScheduler, MODEL_REFRESH_INTERVAL_MS } from './agentModelRefreshScheduler.js'; -import { AgentService } from './agentService.js'; -import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; -import { IAgentHostStateManager } from './agentHostStateManager.js'; -import { IAgentHostPromptCache } from './agentHostPromptCache.js'; -import { IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; -import { AgentHostClaudeAgentEnabledEnvVar, AgentHostClaudeSdkRootEnvVar, AgentHostCodexAgentEnabledEnvVar, IAgentService, AgentHostCodexAgentSdkRootEnvVar, isAgentEnabled } from '../common/agentService.js'; -import { IAgentConfigurationService } from './agentConfigurationService.js'; -import { IAgentHostStorageService } from './agentHostStorageService.js'; -import { IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; -import { IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; -import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; -import { IAgentHostCompletions } from './agentHostCompletions.js'; -import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; +import { AgentHostClaudeAgentEnabledEnvVar, AgentHostClaudeSdkRootEnvVar, AgentHostCodexAgentEnabledEnvVar, AgentHostCodexAgentSdkRootEnvVar, isAgentEnabled } from '../common/agentService.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; import { AgentHostClientConnectionTelemetryTracker } from './agentHostClientConnectionTelemetry.js'; -import { FileService } from '../../files/common/fileService.js'; -import { IFileService } from '../../files/common/files.js'; -import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js'; -import { Schemas } from '../../../base/common/network.js'; -import { ISessionDataService } from '../common/sessionDataService.js'; -import { IDiffComputeService } from '../common/diffComputeService.js'; -import { IAgentEditAttributionService } from '../common/fileEditAttribution.js'; -import { NodeWorkerDiffComputeService } from './diffComputeService.js'; -import { AgentEditAttributionService } from './shared/agentEditAttributionService.js'; -import { IEditSurvivalReporterFactory, EditSurvivalReporterFactory } from './shared/editSurvivalReporter.js'; -import { EditArcReporterService, IEditArcReporterService } from './shared/editArcReporter.js'; -import { SessionDataService } from './sessionDataService.js'; -import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../sandbox/common/terminalSandboxMxcRuntime.js'; -import { ISandboxHelperService } from '../../sandbox/common/sandboxHelperService.js'; -import { SandboxHelperService } from '../../sandbox/node/sandboxHelper.js'; import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; import { resolveServerUrls } from './serverUrls.js'; -import { AgentPluginManager } from './agentPluginManager.js'; -import { IAgentPluginManager } from '../common/agentPluginManager.js'; -import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js'; -import { AgentHostGitService } from './agentHostGitService.js'; -import { IAgentHostGitService } from '../common/agentHostGitService.js'; -import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; -import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; -import { createAgentHostTelemetryService } from './agentHostTelemetryService.js'; -import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import ErrorTelemetry from '../../telemetry/node/errorTelemetry.js'; import { AgentHostLaunchKind } from '../common/agentHostTelemetry.js'; @@ -216,128 +167,44 @@ async function main(): Promise { if (options.quiet) { logService = disposables.add(new LogService(otlpLogger)); } else { - const services = new ServiceCollection(); - services.set(IProductService, productService); - services.set(INativeEnvironmentService, environmentService); loggerService = new LoggerService(getLogLevel(environmentService), environmentService.logsHome); const logger = loggerService.createLogger('agenthost-server', { name: localize('agentHostServer', "Agent Host Server") }); logService = disposables.add(new LogService(logger, [otlpLogger])); - services.set(ILogService, logService); log('Starting standalone agent host server'); } logService.info('[AgentHostServer] Starting standalone agent host server'); - // File service - const fileService = disposables.add(new FileService(logService)); - disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); - // In-memory filesystem backing transient file-edit previews shown during - // tool-call confirmations. - disposables.add(registerPendingEditContentProvider(fileService)); - - // Session data service - const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); - const rootConfigResource = joinPath(environmentService.appSettingsHome, 'globalStorage', 'agent-host-config.json'); - const storageResource = joinPath(environmentService.appSettingsHome, 'globalStorage', 'agent-host-storage.json'); - - // Build the DI container early so the git service can be created via - // `createInstance` (it needs IFileService + INativeEnvironmentService). - // The git service is shared by AgentService (for diff computation + - // showBlob) and the production agent registration path. - const diServices = new ServiceCollection(); - diServices.set(IProductService, productService); - diServices.set(INativeEnvironmentService, environmentService); - diServices.set(ILogService, logService); - diServices.set(IFileService, fileService); - diServices.set(ISessionDataService, sessionDataService); - const networkServices = registerAgentHostNetworkServices(diServices, logService, disposables); - const proxyResolver = networkServices.proxyResolver; - const fetchFn = proxyResolver.fetch.bind(proxyResolver); - const telemetryService = await createAgentHostTelemetryService({ environmentService, productService, fileService, loggerService, logService, disposables, disableTelemetry: options.quiet, fetchFn, requestService: networkServices.requestService }); - errorTelemetry.value = new ErrorTelemetry(telemetryService); - diServices.set(ITelemetryService, telemetryService); - const instantiationService = new InstantiationService(diServices); - const fileMonitorService = disposables.add(instantiationService.createInstance(AgentHostFileMonitorService)); - diServices.set(IAgentHostFileMonitorService, fileMonitorService); - diServices.set(IWindowsMxcTerminalSandboxRuntime, instantiationService.createInstance(WindowsMxcTerminalSandboxRuntime)); - diServices.set(ISandboxHelperService, new SandboxHelperService()); - const gitService = instantiationService.createInstance(AgentHostGitService); - diServices.set(IAgentHostGitService, gitService); - - // Create the agent service (owns AgentHostStateManager + AgentSideEffects internally) - const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)], AgentHostLaunchKind.VSCodeCLI, storageResource, undefined, undefined, { - logsHome: environmentService.logsHome, - tmpDir: environmentService.tmpDir, - }); - disposables.add(agentService); - diServices.set(IAgentService, agentService); - diServices.set(IAgentHostAuthenticationService, agentService.authenticationService); - diServices.set(IAgentHostStateManager, agentService.stateManager); - // Narrow host seams providers consume instead of the whole state manager. - diServices.set(IAgentHostPromptCache, agentService.promptCache); - diServices.set(IAgentHostSessionTitleSignal, agentService.sessionTitleSignal); - diServices.set(IAgentHostManagedSettingsService, agentService.managedSettingsService); - diServices.set(IAgentConfigurationService, agentService.configurationService); - proxyResolver.bindConfigurationService(agentService.configurationService, false); - const networkDiagnosticsService = instantiationService.createInstance(NetworkDiagnosticsService); - diServices.set(INetworkDiagnosticsService, networkDiagnosticsService); - agentService.setNetworkDiagnosticsService(networkDiagnosticsService); - diServices.set(IAgentHostStorageService, agentService.storageService); - diServices.set(IAgentHostCustomizationEnablementService, agentService.customizationEnablementService); - diServices.set(IAgentHostGitHubEndpointService, agentService.gitHubEndpointService); - - // Register agents - let sdkDownloadProgress: Event | undefined; if (!options.quiet) { - // Production agents (require DI) - const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); - diServices.set(IAgentPluginManager, pluginManager); - diServices.set(IDiffComputeService, disposables.add(new NodeWorkerDiffComputeService(logService))); - const editAttributionService = disposables.add(instantiationService.createInstance(AgentEditAttributionService, undefined, undefined)); - diServices.set(IAgentEditAttributionService, editAttributionService); - agentService.setEditAttributionService(editAttributionService); - diServices.set(IEditSurvivalReporterFactory, instantiationService.createInstance(EditSurvivalReporterFactory)); - diServices.set(IAgentHostTerminalManager, agentService.terminalManager); - const editArcReporterService = disposables.add(instantiationService.createInstance(EditArcReporterService, undefined)); - diServices.set(IEditArcReporterService, editArcReporterService); - diServices.set(IAgentHostCompletions, agentService.completionsService); - diServices.set(IAgentHostCheckpointService, agentService.checkpointService); - diServices.set(IAgentHostGitService, gitService); - // Register `ICopilotApiService` BEFORE `IClaudeProxyService` — - // the proxy service constructor requires it. - const copilotApiService = instantiationService.createInstance(CopilotApiService, fetchFn); - diServices.set(ICopilotApiService, copilotApiService); - // Host-owned worktree isolation controller: a single instance drives folder - // / worktree isolation for every agent, so providers stay unaware of it. It - // owns its branch-name generator, created from ICopilotApiService. - const worktreeIsolation = disposables.add(instantiationService.createInstance(WorktreeIsolation, undefined)); - diServices.set(IAgentHostWorktreeIsolation, worktreeIsolation); - agentService.setWorktreeIsolation(worktreeIsolation); - // CLI flags become env vars BEFORE the downloader is constructed so - // `isAvailable()` and `loadSdkRoot()` see them as dev overrides. if (options.claudeSdkRoot) { process.env[AgentHostClaudeSdkRootEnvVar] = options.claudeSdkRoot; } if (options.codexSdkRoot) { process.env[AgentHostCodexAgentSdkRootEnvVar] = options.codexSdkRoot; } - // Register the agent SDK downloader BEFORE any service that injects it. - const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); - diServices.set(IAgentSdkDownloader, agentSdkDownloader); - sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress; - const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); - diServices.set(IClaudeProxyService, claudeProxyService); - const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); - diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); - const codexProxyService = disposables.add(instantiationService.createInstance(CodexProxyService)); - diServices.set(ICodexProxyService, codexProxyService); - const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService, fetchFn)); - diServices.set(IAgentHostOTelService, agentHostOTelService); - // BYOK is unsupported in the remote agent host (no extension host runs - // next to it to serve the renderer LM API). Inject null implementations - // to satisfy CopilotAgent / CopilotSessionLauncher DI. - diServices.set(IByokLmBridgeRegistry, new NullByokLmBridgeRegistry()); - diServices.set(IByokLmProxyService, new NullByokLmProxyService()); + } + + const runtime = await createAgentHostRuntime({ + environmentService, + productService, + logService, + loggerService, + disposables, + disableTelemetry: options.quiet, + transientProxyConfiguration: false, + hostLaunchKind: AgentHostLaunchKind.VSCodeCLI, + providerConfigurations: [createCodexProviderConfiguration(environmentService.userHome)], + byok: { kind: 'unavailable' }, + }); + const { agentService, instantiationService, fileService, sessionDataService } = runtime; + disposables.add(agentService); + errorTelemetry.value = new ErrorTelemetry(runtime.telemetryService); + + // Register agents + let sdkDownloadProgress: Event | undefined; + if (!options.quiet) { + const agentSdkDownloader = runtime.agentSdkDownloader; + sdkDownloadProgress = runtime.sdkDownloadProgress; const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); agentService.registerProvider(copilotAgent); log('CopilotAgent registered'); @@ -506,6 +373,7 @@ async function main(): Promise { logService.warn('[AgentHostServer] Timed out waiting for persistence writes to flush; exiting anyway.'); }); disposables.dispose(); + instantiationService.dispose(); loggerService?.dispose(); process.exit(0); } diff --git a/src/vs/platform/agentHost/node/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index 9fab75587c62b6..3aa6995c59474c 100644 --- a/src/vs/platform/agentHost/node/agentMergeController.ts +++ b/src/vs/platform/agentHost/node/agentMergeController.ts @@ -32,7 +32,7 @@ const backstopInterval = 10 * 60_000; const maximumRepeatedPromptCount = 3; const maximumTotalPromptCount = 6; -interface IAgentMergeControllerOptions { +export interface IAgentMergeControllerOptions { readonly startTurn: (session: string, turnId: string, prompt: string) => boolean; readonly cancelTurn: (session: string, turnId: string) => void; readonly getAutonomousSessionConfig: (session: string, config: Readonly>) => Record | undefined; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index b485175d433ec5..6a7cee7d46f4a1 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -19,8 +19,6 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { hasKey } from '../../../base/common/types.js'; import { localize } from '../../../nls.js'; import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileOperationResult, type FileChangesEvent } from '../../files/common/files.js'; -import { InstantiationService } from '../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentHostAuthTokenRequest, IAgentHostNetworkEndpoint, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; import { AgentHostSessionReleaseGraceMsEnvVar, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; @@ -37,15 +35,14 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_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, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_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, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } 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'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; -import { IProductService } from '../../product/common/productService.js'; import { buildBoundedSideChatSourceContext, getSideChatPartialResponse } from './agentPeerChats.js'; import { AgentConfigurationService, getEffectiveWorkingDirectories, IAgentConfigurationService } from './agentConfigurationService.js'; -import { AgentHostManagedSettingsService, type IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; +import { IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { ISessionDbUriFields, parseSessionDbUri } from '../common/sessionDbUri.js'; import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js'; @@ -53,69 +50,47 @@ import { resolveSessionRepositories } from './agentHostSessionRepositories.js'; import { findDeepestContainingWorkingDirectory, isMultiRootSession } from '../common/agentHostWorkingDirectories.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { createAgentChatContext } from './agentChatContext.js'; -import { AgentHostPromptCache, IAgentHostPromptCache } from './agentHostPromptCache.js'; -import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; +import { IAgentHostPromptCache } from './agentHostPromptCache.js'; +import { IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; import { AgentHostDebugLogsCollector, type IAgentHostDebugLogsEnvironment } from './agentHostDebugLogs.js'; -import { AgentHostDatabase, IAgentHostDatabase } from './agentHostDatabase.js'; +import { IAgentHostDatabase } from './agentHostDatabase.js'; import { AgentSessionRegistry, IRegisteredSession, IStoredRegisteredSession } from './agentSessionRegistry.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; -import { AgentSideEffects } from './agentSideEffects.js'; +import { AgentSideEffects, type IAgentSideEffectsOptions } from './agentSideEffects.js'; import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; -import { buildServerToolGroups } from './shared/serverToolGroups.js'; import { type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, type ISessionServerToolAccessor, validateRenameTitle } from './shared/sessionServerTools.js'; import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts } from '../common/sessionArtifacts.js'; import { buildWorktreeFailureNotification, WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js'; -import { AgentHostChangesetService } from './agentHostChangesetService.js'; -import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js'; -import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; -import { AgentHostChatCompletionProvider } from './agentHostChatCompletionProvider.js'; -import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js'; -import { AgentHostRenameCompletionProvider } from './agentHostRenameCommand.js'; +import { IAgentHostCompletions } from './agentHostCompletions.js'; import { AgentHostSkillCompletionProvider } from './agentHostSkillCompletionProvider.js'; -import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js'; import { SessionServerToolName } from '../common/serverToolNames.js'; -import { CodexCompactCompletionProvider } from './codexCompactCommand.js'; -import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; +import { ICopilotApiService } from './shared/copilotApiService.js'; import { INetworkDiagnosticsService } from './networkDiagnosticsService.js'; import { parseMcpChannelUri } from './shared/mcpCustomizationController.js'; import { toAgentClientUri } from '../common/agentClientUri.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; -import { AgentHostChangesetOperationService } from './agentHostChangesetOperationService.js'; -import { AgentHostGitStateService } from './agentHostGitStateService.js'; -import { AgentHostGitHubEndpointService, IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; -import { AgentMergeController } from './agentMergeController.js'; +import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; +import { AgentMergeController, type IAgentMergeControllerOptions } from './agentMergeController.js'; import { AgentMergeConfigKey, agentMergeRootConfigSchema, readAgentMergeSessionState } from '../common/agentMerge.js'; -import { AgentMergeTools } from './agentMergeTools.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAuthenticationService, type IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; import { AgentHostStorageService, IAgentHostStorageService } from './agentHostStorageService.js'; import { SessionCoordinationService } from './sessionCoordination.js'; -import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; -import { GitHubService, IGitHubService } from '../../github/common/githubService.js'; +import { IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; -import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; -import { AgentHostChangesetSubscriptionService } from './agentHostChangesetSubscriptionService.js'; import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; -import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; -import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js'; -import { AgentHostMergeOperationContribution } from './agentHostMergeOperationProvider.js'; -import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; -import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js'; -import { AgentHostReviewService } from './agentHostReviewService.js'; -import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -324,6 +299,76 @@ function reconcileWorkingDirectories(requested: readonly URI[] | undefined, reso return [...resolved, ...tail].map(d => d.toString()); } +export interface IAgentServiceOptions { + readonly rootConfigResource?: URI; + readonly copilotApiService?: ICopilotApiService; + readonly providerConfigurations?: readonly IAgentCustomizationSettingsRegistration[]; + readonly hostLaunchKind?: AgentHostLaunchKind; + readonly storageResource?: URI; + readonly orchestratorDatabase?: IAgentHostDatabase; + readonly debugLogsEnvironment?: IAgentHostDebugLogsEnvironment; +} + +/** Core state and callbacks exposed only to the Agent Host composition root. */ +export interface IAgentServiceCompositionContext { + readonly stateManager: AgentHostStateManager; + readonly configurationService: AgentConfigurationService; + readonly storageService: AgentHostStorageService; + readonly managedSettingsService: IAgentHostManagedSettingsService; + readonly sessionDataService: ISessionDataService; + readonly agents: IObservable; + readonly hostLaunchKind: AgentHostLaunchKind; + readonly copilotApiServiceOverride: ICopilotApiService | undefined; + readonly getAuthToken: (request: IAgentHostAuthTokenRequest) => string | undefined; + readonly createAgentMergeControllerOptions: () => IAgentMergeControllerOptions; + readonly createSideEffectsOptions: (services: { + readonly localTurns: AgentHostLocalTurns; + readonly copilotApiService: ICopilotApiService; + readonly octoKitService: IAgentHostOctoKitService; + readonly gitStateService: IAgentHostGitStateService; + }) => IAgentSideEffectsOptions; + readonly getSessionMetadata: (session: URI) => Promise; + readonly restoreSession: (session: URI) => Promise; + readonly createSessionServerToolAccessor: () => ISessionServerToolAccessor; + readonly createArtifactServerToolAccessor: () => IArtifactServerToolAccessor; +} + +/** Collaborators constructed by the composition root after registering {@link IAgentService}. */ +export interface IAgentServiceInitialization { + readonly gitHubEndpointService: IAgentHostGitHubEndpointService; + readonly customizationEnablementService: AgentHostCustomizationEnablementService; + readonly gitStateService: IAgentHostGitStateService; + readonly agentMergeController: AgentMergeController; + readonly checkpointService: IAgentHostCheckpointService; + readonly promptCache: IAgentHostPromptCache; + readonly sessionTitleSignal: IAgentHostSessionTitleSignal; + readonly changesetOperationService: IAgentHostChangesetOperationService; + readonly reviewService: IAgentHostReviewService; + readonly changesets: IAgentHostChangesetService; + readonly changesetCoordinator: AgentHostChangesetCoordinator; + readonly completions: IAgentHostCompletions; + readonly terminalManager: AgentHostTerminalManager; + readonly localTurns: AgentHostLocalTurns; + readonly sideEffects: AgentSideEffects; + readonly sessionCoordination: SessionCoordinationService; + readonly serverToolHost: AgentServerToolHost; +} + +/** Core services that must exist before {@link AgentService} can be constructed. */ +export interface IAgentServiceCore { + readonly disposables: DisposableStore; + readonly authenticationService: AgentHostAuthenticationService; + readonly orchestratorDatabase: IAgentHostDatabase; + readonly debugLogsCollector: AgentHostDebugLogsCollector | undefined; + readonly sessionRegistry: AgentSessionRegistry; + readonly stateManager: AgentHostStateManager; + readonly configurationService: AgentConfigurationService; + readonly storageService: AgentHostStorageService; + readonly managedSettingsService: IAgentHostManagedSettingsService; + readonly hostLaunchKind: AgentHostLaunchKind; + readonly copilotApiServiceOverride: ICopilotApiService | undefined; +} + /** * The agent service implementation that runs inside the agent-host utility * process. Dispatches to registered {@link IAgent} instances based @@ -348,8 +393,8 @@ export class AgentService extends Disposable implements IAgentService { /** Authoritative state manager for the sessions process protocol. */ private readonly _stateManager: AgentHostStateManager; - private readonly _sessionCoordination: SessionCoordinationService; - private readonly _managedSettingsService = this._register(new AgentHostManagedSettingsService()); + private _sessionCoordination!: SessionCoordinationService; + private readonly _managedSettingsService: IAgentHostManagedSettingsService; /** * Orchestrator-owned durable index of known sessions. Populated alongside @@ -436,33 +481,32 @@ export class AgentService extends Disposable implements IAgentService { /** Observable registered agents, drives `root/agentsChanged` via {@link AgentSideEffects}. */ private readonly _agents = observableValue('agents', []); /** Shared side-effect handler for action dispatch and session lifecycle. */ - private readonly _sideEffects: AgentSideEffects; - private readonly _agentMergeController: AgentMergeController; + private _sideEffects!: AgentSideEffects; + private _agentMergeController!: AgentMergeController; /** Owns static / per-turn changeset compute, publish, persist, restore. */ - private readonly _changesets: IAgentHostChangesetService; + private _changesets!: IAgentHostChangesetService; /** Shared active changeset subscription registry. */ - private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService; /** Owns changeset operation contributions and handler activation. */ - private readonly _changesetOperationService: IAgentHostChangesetOperationService; - private readonly _reviewService: IAgentHostReviewService; + private _changesetOperationService!: IAgentHostChangesetOperationService; + private _reviewService!: IAgentHostReviewService; /** Owns AgentService-side orchestration of the changeset feature. */ - private readonly _changesetCoordinator: AgentHostChangesetCoordinator; + private _changesetCoordinator!: AgentHostChangesetCoordinator; /** Owns session git-state probing and git-backed catalogue decoration. */ - private readonly _gitStateService: IAgentHostGitStateService; + private _gitStateService!: IAgentHostGitStateService; /** Manages PTY-backed terminals for the agent host protocol. */ - private readonly _terminalManager: AgentHostTerminalManager; + private _terminalManager!: AgentHostTerminalManager; /** Persists host-injected `/rename` / `!command` turns for restore & fork/truncate. */ - private readonly _localTurns: AgentHostLocalTurns; + private _localTurns!: AgentHostLocalTurns; /** Server-side host for the agent host's server tools. */ - private readonly _serverToolHost: AgentServerToolHost; + private _serverToolHost!: AgentServerToolHost; private readonly _debugLogsCollector: AgentHostDebugLogsCollector | undefined; private readonly _configurationService: AgentConfigurationService; private readonly _storageService: AgentHostStorageService; - private readonly _customizationEnablementService: AgentHostCustomizationEnablementService; + private _customizationEnablementService!: AgentHostCustomizationEnablementService; /** Captures baseline / per-turn git checkpoints backing the changeset pipeline. */ - private readonly _checkpointService: IAgentHostCheckpointService; - private readonly _promptCache: IAgentHostPromptCache; - private readonly _sessionTitleSignal: IAgentHostSessionTitleSignal; + private _checkpointService!: IAgentHostCheckpointService; + private _promptCache!: IAgentHostPromptCache; + private _sessionTitleSignal!: IAgentHostSessionTitleSignal; /** * Host-owned worktree isolation controller. Set post-construction via * {@link setWorktreeIsolation} after host startup constructs the Copilot API @@ -473,13 +517,16 @@ export class AgentService extends Disposable implements IAgentService { */ private _worktree: WorktreeIsolation | undefined; /** Single source of truth for GitHub (Enterprise) endpoints and protected resources. */ - private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService; + private _gitHubEndpointService!: IAgentHostGitHubEndpointService; /** Pluggable completion item providers (e.g. workspace file completions, agent-specific @-mentions). */ - private readonly _completions: IAgentHostCompletions; + private _completions!: IAgentHostCompletions; + private _initialized = false; private _skillCompletionProviderRegistered = false; /** Backs {@link getNetworkDiagnosticsInfo} / {@link diagnosticsFetch}; wired via {@link setNetworkDiagnosticsService}. */ private _networkDiagnostics: INetworkDiagnosticsService | undefined; private _editAttributionService: IAgentEditAttributionService | undefined; + private readonly _hostLaunchKind: AgentHostLaunchKind; + private readonly _copilotApiServiceOverride: ICopilotApiService | undefined; /** * Authoritative server-side per-resource subscription refcount, keyed by @@ -562,41 +609,23 @@ export class AgentService extends Disposable implements IAgentService { get completionTriggerCharacters(): readonly string[] { return this._completions.triggerCharacters; } constructor( - private readonly _logService: ILogService, - private readonly _fileService: IFileService, - private readonly _sessionDataService: ISessionDataService, - private readonly _productService: IProductService, - private readonly _gitService: IAgentHostGitService, - private readonly _rootConfigResource?: URI, - private readonly _telemetryService: ITelemetryService = NullTelemetryService, - _fileMonitorService?: IAgentHostFileMonitorService, - copilotApiService?: ICopilotApiService, - fetchFn?: typeof globalThis.fetch, - providerConfigurations: readonly IAgentCustomizationSettingsRegistration[] = [], - private readonly _hostLaunchKind = AgentHostLaunchKind.Unknown, - storageResource?: URI, - orchestratorDatabase?: IAgentHostDatabase, - private readonly _now: () => number = Date.now, - debugLogsEnvironment?: IAgentHostDebugLogsEnvironment, + core: IAgentServiceCore, + @ILogService private readonly _logService: ILogService, + @IFileService private readonly _fileService: IFileService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); + this._register(core.disposables); + this._hostLaunchKind = core.hostLaunchKind; + this._copilotApiServiceOverride = core.copilotApiServiceOverride; this._logService.info('AgentService initialized'); - this._authService = this._register(new AgentHostAuthenticationService(_logService)); - const databasePath = this._rootConfigResource - ? joinPath(resourcesDirname(this._rootConfigResource), 'agent-host.db').fsPath - : ':memory:'; - this._orchestratorDatabase = this._register(orchestratorDatabase ?? new AgentHostDatabase(databasePath)); - this._debugLogsCollector = debugLogsEnvironment ? this._register(new AgentHostDebugLogsCollector(debugLogsEnvironment, this._logService)) : undefined; - this._sessionRegistry = this._register(new AgentSessionRegistry(this._orchestratorDatabase)); - this._stateManager = this._register(new AgentHostStateManager(_logService, { - hostBuildInfo: hostBuildInfoFromProduct(this._productService), - changesetStateRetention: { - // The cache calls this lazily after construction. If a future state-manager - // initialization path registers changesets before `_changesets` is assigned, - // keep the entry pinned rather than evicting with incomplete liveness data. - canEvict: changeset => this._changesets ? this._isChangesetEvictable(changeset) : false, - }, - })); + this._authService = core.authenticationService; + this._orchestratorDatabase = core.orchestratorDatabase; + this._debugLogsCollector = core.debugLogsCollector; + this._sessionRegistry = core.sessionRegistry; + this._stateManager = core.stateManager; this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e))); this._register(this._stateManager.onDidEmitEnvelope(e => this._trackPendingSubagentChatFromEnvelope(e))); this._register(this._stateManager.onDidEmitEnvelope(e => this._persistAnnotations(e))); @@ -617,15 +646,98 @@ export class AgentService extends Disposable implements IAgentService { this._queueSessionListReconciliation(); } })); - // Build a local instantiation scope so downstream components can - // consume {@link IAgentConfigurationService} (and later {@link ILogService}) - // via DI rather than being plumbed plain-class references. - const configurationService = this._register(new AgentConfigurationService(this._stateManager, this._logService, this._rootConfigResource, providerConfigurations)); - this._configurationService = configurationService; + this._configurationService = core.configurationService; + this._storageService = core.storageService; + this._managedSettingsService = core.managedSettingsService; + updateAgentHostTelemetryLevelFromConfig(this._telemetryService, this._stateManager.rootState.config?.values); + } + + /** Returns the narrow state and callback surface needed to compose collaborators. */ + getCompositionContext(): IAgentServiceCompositionContext { + return { + stateManager: this._stateManager, + configurationService: this._configurationService, + storageService: this._storageService, + managedSettingsService: this._managedSettingsService, + sessionDataService: this._sessionDataService, + agents: this._agents, + hostLaunchKind: this._hostLaunchKind, + copilotApiServiceOverride: this._copilotApiServiceOverride, + getAuthToken: request => this._authService.getAuthToken(request), + createAgentMergeControllerOptions: () => ({ + startTurn: (session, turnId, prompt) => this._startAgentMergePrompt(session, turnId, prompt), + cancelTurn: (session, turnId) => this._cancelAgentMergePrompt(session, turnId), + getAutonomousSessionConfig: (session, config) => this._findProviderForSession(session)?.getAutonomousSessionConfig?.(config), + }), + createSideEffectsOptions: services => ({ + getAgent: session => this._findProviderForSession(session), + sessionDataService: this._sessionDataService, + localTurns: services.localTurns, + agents: this._agents, + hostLaunchKind: this._hostLaunchKind, + copilotApiService: services.copilotApiService, + getGitHubCopilotToken: () => { + const resource = this._gitHubEndpointService.getCopilotResource(); + return this._authService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); + }, + getGitHubToken: () => { + const resource = this._gitHubEndpointService.getRepoResource(); + return this._authService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); + }, + getGitHubHost: () => this._gitHubEndpointService.getEnterpriseHost() ?? 'github.com', + octoKitService: services.octoKitService, + resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params), + resolveChatAttachmentTurns: resource => this._resolveChatAttachmentTurns(resource), + onTurnComplete: session => { + const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; + void services.gitStateService.attachSessionGitHubPullRequest(session, workingDirStr ? URI.parse(workingDirStr) : undefined); + }, + onUserMessage: (session, text) => { + void services.gitStateService.attachSessionGitHubReferences(session.toString(), text); + }, + }), + getSessionMetadata: session => this._getSessionMetadata(session), + restoreSession: session => this.restoreSession(session), + createSessionServerToolAccessor: () => this._createSessionServerToolAccessor(), + createArtifactServerToolAccessor: () => this._createArtifactServerToolAccessor(), + }; + } + + /** Completes the one-time wiring of collaborators that depend on {@link IAgentService}. */ + initialize(initialization: IAgentServiceInitialization): void { + if (this._initialized) { + throw new Error('AgentService has already been initialized'); + } + this._initialized = true; + this._gitHubEndpointService = initialization.gitHubEndpointService; + this._customizationEnablementService = initialization.customizationEnablementService; + this._gitStateService = initialization.gitStateService; + this._agentMergeController = initialization.agentMergeController; + this._checkpointService = initialization.checkpointService; + this._promptCache = initialization.promptCache; + this._sessionTitleSignal = initialization.sessionTitleSignal; + this._changesetOperationService = initialization.changesetOperationService; + this._reviewService = initialization.reviewService; + this._changesets = initialization.changesets; + this._changesetCoordinator = initialization.changesetCoordinator; + this._completions = initialization.completions; + this._terminalManager = initialization.terminalManager; + this._localTurns = initialization.localTurns; + this._sideEffects = initialization.sideEffects; + this._sessionCoordination = initialization.sessionCoordination; + this._serverToolHost = initialization.serverToolHost; + this._register(this._stateManager.onDidChangeSessionConfig(({ session, previous, current }) => this._syncAgentMergeIndex(URI.parse(session), previous, current))); + this._register(this._agentMergeController.onDidReleaseHold(session => { + const resource = URI.parse(session); + if (!this._hasSessionSubscribers(resource) && this._stateManager.getSessionState(session)) { + this._scheduleSessionRelease(resource); + } + })); + let externalSessionsMode = this._getExternalSessionsMode(); this._lastMigrateLegacyEnabled = this._isMigrateLegacyEnabled(); let agentMergeEnabled = this._isAgentMergeEnabled(); - this._register(configurationService.onDidRootConfigChange(() => { + this._register(this._configurationService.onDidRootConfigChange(() => { const nextMode = this._getExternalSessionsMode(); if (nextMode !== externalSessionsMode) { const previousMode = externalSessionsMode; @@ -633,8 +745,6 @@ export class AgentService extends Disposable implements IAgentService { this._logService.info(`[AgentService] ${AgentHostShowExternalSessionsConfigKey} changed '${previousMode}' -> '${nextMode}'; queueing session list reconciliation`); this._queueSessionListReconciliation(previousMode); } - // Agent Merge tools are only advertised while the feature is on, so a - // toggle has to reach sessions that were advertised under the old value. const nextAgentMergeEnabled = this._isAgentMergeEnabled(); if (nextAgentMergeEnabled !== agentMergeEnabled) { agentMergeEnabled = nextAgentMergeEnabled; @@ -651,199 +761,12 @@ export class AgentService extends Disposable implements IAgentService { } this._onMigrateLegacySettingChanged(); })); - const fileMonitorService = _fileMonitorService ?? this._register(new AgentHostFileMonitorService(this._fileService, this._logService)); - this._storageService = this._register(new AgentHostStorageService(storageResource, this._logService)); - updateAgentHostTelemetryLevelFromConfig(this._telemetryService, this._stateManager.rootState.config?.values); - const services = new ServiceCollection( - [ILogService, this._logService], - [IAgentService, this], - [IProductService, this._productService], - [IAgentConfigurationService, configurationService], - [IAgentHostStateManager, this._stateManager], - [IAgentHostFileMonitorService, fileMonitorService], - [IAgentHostGitService, this._gitService], - [IAgentHostStorageService, this._storageService], - [ITelemetryService, this._telemetryService], - // The outer agent-host process DI registers `ISessionDataService`, - // but this nested strict `InstantiationService` does not inherit it. - // Add it explicitly so `@ISessionDataService` injection into the - // changeset service (and any future sibling) resolves correctly. - [ISessionDataService, this._sessionDataService], - ); - const instantiationService = this._register(new InstantiationService(services, /*strict*/ true)); - this._gitHubEndpointService = this._register(instantiationService.createInstance(AgentHostGitHubEndpointService)); - services.set(IAgentHostGitHubEndpointService, this._gitHubEndpointService); - // A GitHub Enterprise URI change repoints every agent's GitHub resource - // identity to a different authorization server, so the client must obtain a - // token for the new resource. One root-channel `auth/required` covers all - // agents (the URI is host-level config). this._register(this._gitHubEndpointService.onDidChange(() => { this._stateManager.emitAuthRequired({ resource: this._gitHubEndpointService.getCopilotResource(), reason: AuthRequiredReason.Required, }); })); - const agentHostOctoKitService = instantiationService.createInstance(AgentHostOctoKitService, fetchFn); - services.set(IAgentHostOctoKitService, agentHostOctoKitService); - const gitHubService = this._register(instantiationService.createInstance(GitHubService, { - endpoint: this._gitHubEndpointService, - tokenProvider: { - getToken: () => { - const resource = this._gitHubEndpointService.getRepoResource(); - return this._authService.getAuthToken({ - resource: resource.resource, - scopes: resource.scopes_supported, - }); - }, - }, - fetch: fetchFn, - })); - services.set(IGitHubService, gitHubService); - const effectiveCopilotApiService = copilotApiService ?? instantiationService.createInstance(CopilotApiService, fetchFn); - services.set(ICopilotApiService, effectiveCopilotApiService); - this._customizationEnablementService = this._register(instantiationService.createInstance(AgentHostCustomizationEnablementService)); - services.set(IAgentHostCustomizationEnablementService, this._customizationEnablementService); - - this._gitStateService = this._register(instantiationService.createInstance(AgentHostGitStateService)); - services.set(IAgentHostGitStateService, this._gitStateService); - this._agentMergeController = this._register(instantiationService.createInstance(AgentMergeController, { - startTurn: (session, turnId, prompt) => this._startAgentMergePrompt(session, turnId, prompt), - cancelTurn: (session, turnId) => this._cancelAgentMergePrompt(session, turnId), - getAutonomousSessionConfig: (session, config) => this._findProviderForSession(session)?.getAutonomousSessionConfig?.(config), - })); - this._register(this._stateManager.onDidChangeSessionConfig(({ session, previous, current }) => this._syncAgentMergeIndex(URI.parse(session), previous, current))); - // A held session skipped its idle release; re-arm it once the hold ends. - this._register(this._agentMergeController.onDidReleaseHold(session => { - const resource = URI.parse(session); - if (!this._hasSessionSubscribers(resource) && this._stateManager.getSessionState(session)) { - this._scheduleSessionRelease(resource); - } - })); - - this._checkpointService = this._register(instantiationService.createInstance(AgentHostCheckpointService)); - services.set(IAgentHostCheckpointService, this._checkpointService); - - this._promptCache = instantiationService.createInstance(AgentHostPromptCache); - services.set(IAgentHostPromptCache, this._promptCache); - this._sessionTitleSignal = this._register(instantiationService.createInstance(AgentHostSessionTitleSignal)); - services.set(IAgentHostSessionTitleSignal, this._sessionTitleSignal); - - // The subscription service manages the lifecycle of changeset subscriptions. The service - // is also consulted by other services when refreshing changesets and changeset operations. - this._changesetSubscriptions = instantiationService.createInstance(AgentHostChangesetSubscriptionService); - services.set(IAgentHostChangesetSubscriptionService, this._changesetSubscriptions); - - // The operation contribution service manages the lifecycle of changeset operations. - this._changesetOperationService = this._register(instantiationService.createInstance(AgentHostChangesetOperationService)); - services.set(IAgentHostChangesetOperationService, this._changesetOperationService); - - // The changes review service is responsible for managing review/unreview state for changeset changes. - this._reviewService = this._register(instantiationService.createInstance(AgentHostReviewService)); - services.set(IAgentHostReviewService, this._reviewService); - - // The changeset service is responsible for computing, publishing, and persisting changesets. - this._changesets = this._register(instantiationService.createInstance(AgentHostChangesetService)); - services.set(IAgentHostChangesetService, this._changesets); - - // The coordinator owns all AgentService-side orchestration of the changeset feature: lifecycle - // hooks, listSessions overlay, subscription URI routing, and the deferred-refresh state machine. - this._changesetCoordinator = this._register(instantiationService.createInstance(AgentHostChangesetCoordinator)); - this._register(this._stateManager.onDidChangeSessionActiveTurn(e => this._changesetCoordinator.onSessionTurnActiveChanged(e.session, e.active))); - - // Register the changeset operation contributions. - this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostCommitOperationContribution))); - this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution))); - this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostMergeOperationContribution))); - this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution))); - this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution))); - - this._completions = this._register(instantiationService.createInstance(AgentHostCompletions)); - // Built-in generic provider: completes files in the session's workspace folder. - const workspaceFiles = this._register(instantiationService.createInstance(AgentHostWorkspaceFiles)); - this._register(this._completions.registerProvider( - new AgentHostFileCompletionProvider(this._stateManager, workspaceFiles, this._logService), - )); - // Built-in generic provider: completes `#chat:` references to other - // chats in the same session, attaching a chat transcript attachment. - this._register(this._completions.registerProvider( - new AgentHostChatCompletionProvider(this._stateManager), - )); - // Built-in generic provider: offers the `/rename` slash command for any - // session that already has history. Execution is handled server-side in - // AgentSideEffects (redirected to a SessionTitleChanged action). - this._register(this._completions.registerProvider( - new AgentHostRenameCompletionProvider( - session => (this._stateManager.getSessionState(session)?.turns.length ?? 0) > 0, - ), - )); - this._register(this._completions.registerProvider( - new CodexCompactCompletionProvider( - session => (this._stateManager.getSessionState(session)?.turns.length ?? 0) > 0, - ), - )); - - // Terminal management — the terminal manager listens to the state - // manager's action stream and dispatches PTY output back through it. - // Created before AgentSideEffects and registered in the local scope so - // AgentSideEffects can consume it via DI (for inline `!command` - // execution). - this._terminalManager = this._register(instantiationService.createInstance(AgentHostTerminalManager)); - services.set(IAgentHostTerminalManager, this._terminalManager); - - this._localTurns = new AgentHostLocalTurns(this._sessionDataService, this._logService); - - this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, this._customizationEnablementService, { - getAgent: session => this._findProviderForSession(session), - sessionDataService: this._sessionDataService, - localTurns: this._localTurns, - agents: this._agents, - hostLaunchKind: this._hostLaunchKind, - copilotApiService: effectiveCopilotApiService, - getGitHubCopilotToken: () => { - return this.getAuthToken({ - resource: this._gitHubEndpointService.getCopilotResource().resource, - scopes: this._gitHubEndpointService.getCopilotResource().scopes_supported, - }); - }, - getGitHubToken: () => { - return this.getAuthToken({ - resource: this._gitHubEndpointService.getRepoResource().resource, - scopes: this._gitHubEndpointService.getRepoResource().scopes_supported, - }); - }, - getGitHubHost: () => this._gitHubEndpointService.getEnterpriseHost() ?? 'github.com', - octoKitService: agentHostOctoKitService, - resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params), - resolveChatAttachmentTurns: resource => this._resolveChatAttachmentTurns(resource), - onTurnComplete: session => { - const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; - void this._gitStateService.attachSessionGitHubPullRequest(session, workingDirStr ? URI.parse(workingDirStr) : undefined); - }, - onUserMessage: (session, text) => { - void this._gitStateService.attachSessionGitHubReferences(session.toString(), text); - }, - })); - this._sessionCoordination = this._register(new SessionCoordinationService( - this._stateManager, - this._sessionDataService, - this._logService, - { - getSessionMetadata: session => this._getSessionMetadata(session), - restoreSession: session => this.restoreSession(session), - handleAction: (chat, action) => this._sideEffects.handleAction(chat, action), - }, - )); - - // Server-side tools, executed in-process against each session's own - // state. The set of groups (and their display) is the single source of - // truth in `serverToolGroups.ts`; the session-management group's runtime - // dependency (this service) is injected via the accessor. - const agentMergeTools = instantiationService.createInstance( - AgentMergeTools, - () => this._agentMergeController.isEnabled(), - session => this._agentMergeController.getTurnContext(session), - ); - this._serverToolHost = new AgentServerToolHost(this._stateManager, buildServerToolGroups(this._createSessionServerToolAccessor(), agentMergeTools, this._createArtifactServerToolAccessor())); this._scheduleExternalSessionPrune(); } @@ -874,7 +797,7 @@ export class AgentService extends Disposable implements IAgentService { } private async _pruneStaleExternalSessions(): Promise<void> { - const now = this._now(); + const now = Date.now(); const registered = await this._listRegisteredSessions(); const staleExternalSessions: URI[] = []; for (const entry of registered) { @@ -917,9 +840,8 @@ export class AgentService extends Disposable implements IAgentService { /** * Injects the host-owned {@link WorktreeIsolation} controller and forwards it - * to the collaborators that consult it. Called once at startup (from - * agentHostMain / agentHostServerMain) after the Copilot API dependencies - * have been wired. + * to the collaborators that consult it. Called by provider-infrastructure + * composition after the Copilot API dependencies have been wired. */ setWorktreeIsolation(worktree: WorktreeIsolation): void { this._worktree = worktree; @@ -1677,7 +1599,7 @@ export class AgentService extends Disposable implements IAgentService { suppressed++; return false; } - if (external && !readSessionEhcliAdoptable(sessionMetadata._meta) && this._isExternalSessionOlderThanMaxAge(sessionMetadata.modifiedTime, this._now())) { + if (external && !readSessionEhcliAdoptable(sessionMetadata._meta) && this._isExternalSessionOlderThanMaxAge(sessionMetadata.modifiedTime, Date.now())) { skippedAsStale++; return false; } @@ -1747,7 +1669,7 @@ export class AgentService extends Disposable implements IAgentService { continue; } const metadata = sessions[index]; - if (identity.external && !readSessionEhcliAdoptable(metadata._meta) && this._isExternalSessionOlderThanMaxAge(metadata.modifiedTime, this._now())) { + if (identity.external && !readSessionEhcliAdoptable(metadata._meta) && this._isExternalSessionOlderThanMaxAge(metadata.modifiedTime, Date.now())) { continue; } const registered = await this._sessionRegistry.register(identity.session, identity, { checkTombstone: true }); @@ -2107,7 +2029,7 @@ export class AgentService extends Disposable implements IAgentService { }); } const combined = additions.length > 0 ? [...withStatus, ...additions] : withStatus; - const now = this._now(); + const now = Date.now(); const recentSessionKeys = mode === AgentHostExternalSessionsMode.Recent ? this._getRecentSessionKeys(combined, now) : undefined; @@ -2193,7 +2115,7 @@ export class AgentService extends Disposable implements IAgentService { private _shouldIncludeSession( session: IAgentSessionMetadata, mode = this._getExternalSessionsMode(), - now = this._now(), + now = Date.now(), recentSessionKeys?: ReadonlySet<string>, ): boolean { // While migration is off, un-adopted adoptable-legacy sessions belong to the extension-host provider — exclude so a refresh cannot re-surface an unopenable row. @@ -2403,7 +2325,7 @@ export class AgentService extends Disposable implements IAgentService { previousMode: AgentHostExternalSessionsMode, previouslyExposed: Set<string>, ): IAgentSessionMetadata[] { - const now = this._now(); + const now = Date.now(); const recentKeysFor = (mode: AgentHostExternalSessionsMode) => mode === AgentHostExternalSessionsMode.Recent ? this._getRecentSessionKeys(superset, now) : undefined; @@ -4289,8 +4211,8 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.removeSession(evictionTargetKey); } - // Returns true when a changeset is safe to drop from the in-memory cache. - private _isChangesetEvictable(changeset: string): boolean { + /** Returns true when a changeset is safe to drop from the in-memory cache. */ + canEvictChangeset(changeset: string): boolean { const changesetUri = URI.parse(changeset); // A direct changeset subscriber is rendering this expanded URI. Keep // the state alive so future envelopes still target an existing object. diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts new file mode 100644 index 00000000000000..2895a90c533ffe --- /dev/null +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -0,0 +1,237 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore, type IDisposable } from '../../../base/common/lifecycle.js'; +import { dirname, joinPath } from '../../../base/common/resources.js'; +import { GitHubService, IGitHubService } from '../../github/common/githubService.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; +import { ILogService } from '../../log/common/log.js'; +import { IProductService } from '../../product/common/productService.js'; +import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; +import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; +import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; +import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; +import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; +import { IAgentService } from '../common/agentService.js'; +import { AgentHostLaunchKind } from '../common/agentHostTelemetry.js'; +import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; +import { AgentHostAuthenticationService, IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; +import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js'; +import { AgentHostChangesetOperationService } from './agentHostChangesetOperationService.js'; +import { AgentHostChangesetService } from './agentHostChangesetService.js'; +import { AgentHostChangesetSubscriptionService } from './agentHostChangesetSubscriptionService.js'; +import { AgentHostChatCompletionProvider } from './agentHostChatCompletionProvider.js'; +import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; +import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; +import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; +import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; +import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js'; +import { AgentHostDebugLogsCollector } from './agentHostDebugLogs.js'; +import { AgentHostDatabase } from './agentHostDatabase.js'; +import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js'; +import { AgentHostGitStateService } from './agentHostGitStateService.js'; +import { AgentHostGitHubEndpointService, IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; +import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; +import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; +import { AgentHostMergeOperationContribution } from './agentHostMergeOperationProvider.js'; +import { AgentHostPromptCache, IAgentHostPromptCache } from './agentHostPromptCache.js'; +import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; +import { AgentHostRenameCompletionProvider } from './agentHostRenameCommand.js'; +import { AgentHostReviewService } from './agentHostReviewService.js'; +import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; +import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; +import { AgentHostStorageService, IAgentHostStorageService } from './agentHostStorageService.js'; +import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js'; +import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js'; +import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js'; +import { AgentMergeController } from './agentMergeController.js'; +import { AgentMergeTools } from './agentMergeTools.js'; +import { AgentService, type IAgentServiceCore, type IAgentServiceOptions } from './agentService.js'; +import { AgentSessionRegistry } from './agentSessionRegistry.js'; +import { AgentSideEffects } from './agentSideEffects.js'; +import { CodexCompactCompletionProvider } from './codexCompactCommand.js'; +import { SessionCoordinationService } from './sessionCoordination.js'; +import { AgentServerToolHost } from './shared/agentServerToolHost.js'; +import { buildServerToolGroups } from './shared/serverToolGroups.js'; +import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; +import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; +import { hostBuildInfoFromProduct } from '../common/state/sessionState.js'; + +/** Constructs, registers, and initializes the complete {@link AgentService} collaborator graph. */ +export function createAgentService( + options: IAgentServiceOptions, + services: ServiceCollection, + instantiationService: IInstantiationService, + fetchFn: typeof globalThis.fetch, + logService: ILogService, + productService: IProductService, + additionalDisposables: readonly IDisposable[] = [], +): AgentService { + const owned = new DisposableStore(); + let agentService: AgentService | undefined; + try { + for (const disposable of additionalDisposables) { + owned.add(disposable); + } + const databasePath = options.rootConfigResource + ? joinPath(dirname(options.rootConfigResource), 'agent-host.db').fsPath + : ':memory:'; + const orchestratorDatabase = owned.add(options.orchestratorDatabase ?? new AgentHostDatabase(databasePath)); + const debugLogsCollector = options.debugLogsEnvironment + ? owned.add(new AgentHostDebugLogsCollector(options.debugLogsEnvironment, logService)) + : undefined; + const sessionRegistry = owned.add(new AgentSessionRegistry(orchestratorDatabase)); + const stateManager = owned.add(new AgentHostStateManager(logService, { + hostBuildInfo: hostBuildInfoFromProduct(productService), + changesetStateRetention: { + canEvict: changeset => agentService?.canEvictChangeset(changeset) ?? false, + }, + })); + const configurationService = owned.add(new AgentConfigurationService( + stateManager, + logService, + options.rootConfigResource, + options.providerConfigurations ?? [], + )); + const storageService = owned.add(new AgentHostStorageService(options.storageResource, logService)); + const managedSettingsService = owned.add(new AgentHostManagedSettingsService()); + const core: IAgentServiceCore = { + disposables: owned, + authenticationService: owned.add(new AgentHostAuthenticationService(logService)), + orchestratorDatabase, + debugLogsCollector, + sessionRegistry, + stateManager, + configurationService, + storageService, + managedSettingsService, + hostLaunchKind: options.hostLaunchKind ?? AgentHostLaunchKind.Unknown, + copilotApiServiceOverride: options.copilotApiService, + }; + agentService = instantiationService.createInstance(AgentService, core); + const context = agentService.getCompositionContext(); + services.set(IAgentService, agentService); + services.set(IAgentHostAuthenticationService, core.authenticationService); + services.set(IAgentConfigurationService, context.configurationService); + services.set(IAgentHostStateManager, context.stateManager); + services.set(IAgentHostStorageService, context.storageService); + services.set(IAgentHostManagedSettingsService, context.managedSettingsService); + + const gitHubEndpointService = owned.add(instantiationService.createInstance(AgentHostGitHubEndpointService)); + services.set(IAgentHostGitHubEndpointService, gitHubEndpointService); + const octoKitService = instantiationService.createInstance(AgentHostOctoKitService, fetchFn); + services.set(IAgentHostOctoKitService, octoKitService); + const gitHubService = owned.add(instantiationService.createInstance(GitHubService, { + endpoint: gitHubEndpointService, + tokenProvider: { + getToken: () => { + const resource = gitHubEndpointService.getRepoResource(); + return context.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); + }, + }, + fetch: fetchFn, + })); + services.set(IGitHubService, gitHubService); + const copilotApiService = context.copilotApiServiceOverride ?? instantiationService.createInstance(CopilotApiService, fetchFn); + services.set(ICopilotApiService, copilotApiService); + const customizationEnablementService = owned.add(instantiationService.createInstance(AgentHostCustomizationEnablementService)); + services.set(IAgentHostCustomizationEnablementService, customizationEnablementService); + const gitStateService = owned.add(instantiationService.createInstance(AgentHostGitStateService)); + services.set(IAgentHostGitStateService, gitStateService); + const agentMergeController = owned.add(instantiationService.createInstance(AgentMergeController, context.createAgentMergeControllerOptions())); + const checkpointService = owned.add(instantiationService.createInstance(AgentHostCheckpointService)); + services.set(IAgentHostCheckpointService, checkpointService); + const promptCache = instantiationService.createInstance(AgentHostPromptCache); + services.set(IAgentHostPromptCache, promptCache); + const sessionTitleSignal = owned.add(instantiationService.createInstance(AgentHostSessionTitleSignal)); + services.set(IAgentHostSessionTitleSignal, sessionTitleSignal); + const changesetSubscriptions = instantiationService.createInstance(AgentHostChangesetSubscriptionService); + services.set(IAgentHostChangesetSubscriptionService, changesetSubscriptions); + const changesetOperationService = owned.add(instantiationService.createInstance(AgentHostChangesetOperationService)); + services.set(IAgentHostChangesetOperationService, changesetOperationService); + const reviewService = owned.add(instantiationService.createInstance(AgentHostReviewService)); + services.set(IAgentHostReviewService, reviewService); + const changesets = owned.add(instantiationService.createInstance(AgentHostChangesetService)); + services.set(IAgentHostChangesetService, changesets); + const changesetCoordinator = owned.add(instantiationService.createInstance(AgentHostChangesetCoordinator)); + owned.add(context.stateManager.onDidChangeSessionActiveTurn(event => changesetCoordinator.onSessionTurnActiveChanged(event.session, event.active))); + owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostCommitOperationContribution))); + owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution))); + owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostMergeOperationContribution))); + owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution))); + owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution))); + + const completions = owned.add(instantiationService.createInstance(AgentHostCompletions)); + services.set(IAgentHostCompletions, completions); + const workspaceFiles = owned.add(instantiationService.createInstance(AgentHostWorkspaceFiles)); + owned.add(completions.registerProvider(new AgentHostFileCompletionProvider(context.stateManager, workspaceFiles, logService))); + owned.add(completions.registerProvider(new AgentHostChatCompletionProvider(context.stateManager))); + owned.add(completions.registerProvider(new AgentHostRenameCompletionProvider( + session => (context.stateManager.getSessionState(session)?.turns.length ?? 0) > 0, + ))); + owned.add(completions.registerProvider(new CodexCompactCompletionProvider( + session => (context.stateManager.getSessionState(session)?.turns.length ?? 0) > 0, + ))); + + const terminalManager = owned.add(instantiationService.createInstance(AgentHostTerminalManager)); + services.set(IAgentHostTerminalManager, terminalManager); + const localTurns = new AgentHostLocalTurns(context.sessionDataService, logService); + const sideEffects = owned.add(instantiationService.createInstance( + AgentSideEffects, + context.stateManager, + customizationEnablementService, + context.createSideEffectsOptions({ localTurns, copilotApiService, octoKitService, gitStateService }), + )); + const sessionCoordination = owned.add(new SessionCoordinationService( + context.stateManager, + context.sessionDataService, + logService, + { + getSessionMetadata: context.getSessionMetadata, + restoreSession: context.restoreSession, + handleAction: (chat, action) => sideEffects.handleAction(chat, action), + }, + )); + const agentMergeTools = instantiationService.createInstance( + AgentMergeTools, + () => agentMergeController.isEnabled(), + session => agentMergeController.getTurnContext(session), + ); + const serverToolHost = new AgentServerToolHost( + context.stateManager, + buildServerToolGroups(context.createSessionServerToolAccessor(), agentMergeTools, context.createArtifactServerToolAccessor()), + ); + + agentService.initialize({ + gitHubEndpointService, + customizationEnablementService, + gitStateService, + agentMergeController, + checkpointService, + promptCache, + sessionTitleSignal, + changesetOperationService, + reviewService, + changesets, + changesetCoordinator, + completions, + terminalManager, + localTurns, + sideEffects, + sessionCoordination, + serverToolHost, + }); + return agentService; + } catch (error) { + if (agentService) { + agentService.dispose(); + } else { + owned.dispose(); + } + throw error; + } +} diff --git a/src/vs/platform/agentHost/test/node/agentHostBootstrap.test.ts b/src/vs/platform/agentHost/test/node/agentHostBootstrap.test.ts index a44d3b0b6a7201..aea89b8ad49262 100644 --- a/src/vs/platform/agentHost/test/node/agentHostBootstrap.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostBootstrap.test.ts @@ -4,13 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { mkdirSync, mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../base/common/path.js'; +import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { parseArgs, OPTIONS } from '../../../environment/node/argv.js'; +import { NativeEnvironmentService } from '../../../environment/node/environmentService.js'; import { NullLogService } from '../../../log/common/log.js'; +import product from '../../../product/common/product.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { IRequestService } from '../../../request/common/request.js'; -import { registerAgentHostNetworkServices } from '../../node/agentHostBootstrap.js'; +import { createAgentHostRuntime, registerAgentHostNetworkServices } from '../../node/agentHostBootstrap.js'; import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; +import { NullByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +import { AgentHostLaunchKind } from '../../common/agentHostTelemetry.js'; suite('agentHostBootstrap', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -28,4 +36,30 @@ suite('agentHostBootstrap', () => { requestService: true, }); }); + + test('constructs the renderer BYOK runtime with strict dependency injection', async () => { + const testDisposables = disposables.add(new DisposableStore()); + const userDataPath = mkdtempSync(join(tmpdir(), 'agent-host-bootstrap-')); + mkdirSync(join(userDataPath, 'User', 'globalStorage'), { recursive: true }); + testDisposables.add(toDisposable(() => rmSync(userDataPath, { recursive: true, force: true }))); + const productService = { _serviceBrand: undefined, ...product }; + const environmentService = new NativeEnvironmentService(parseArgs(['--user-data-dir', userDataPath, '--force-disable-user-env'], OPTIONS), productService); + + const runtime = await createAgentHostRuntime({ + environmentService, + productService, + logService: new NullLogService(), + loggerService: undefined, + disposables: testDisposables, + disableTelemetry: true, + transientProxyConfiguration: true, + hostLaunchKind: AgentHostLaunchKind.Unknown, + providerConfigurations: [], + byok: { kind: 'renderer', bridgeRegistry: new NullByokLmBridgeRegistry() }, + }); + testDisposables.add(runtime.agentService); + testDisposables.add(runtime.instantiationService); + + assert.ok(runtime.agentSdkDownloader); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index ada5a080d5730c..bd3d854d6ea1db 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -40,7 +40,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType } from '../../common/state/sessionActions.js'; -import { AH_META_IS_READ_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_IS_READ_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { IProductService } from '../../../product/common/productService.js'; @@ -64,6 +64,7 @@ import { SessionServerToolName } from '../../common/serverToolNames.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; +import { createTestAgentService } from './agentServiceTestUtils.js'; /** * Replace individual operations on an agent's chat surface, delegating every @@ -463,7 +464,7 @@ suite('AgentService (node dispatcher)', () => { await fileService.createFolder(URI.from({ scheme: Schemas.inMemory, path: '/testDir' })); await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello')); - service = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + service = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); copilotAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => copilotAgent.dispose())); }); @@ -681,7 +682,7 @@ suite('AgentService (node dispatcher)', () => { gitService.revParse = async () => 'head'; gitService.getCurrentBranch = async () => 'feature'; gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' }); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); localService.setWorktreeIsolation(disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, gitService, @@ -727,7 +728,7 @@ suite('AgentService (node dispatcher)', () => { gitService.revParse = async () => 'head'; gitService.getCurrentBranch = async () => 'feature'; gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' }); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); localService.setWorktreeIsolation(disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, gitService, @@ -856,7 +857,7 @@ suite('AgentService (node dispatcher)', () => { gitService.revParse = async () => 'head'; gitService.getCurrentBranch = async () => 'feature'; gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' }); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const isolation = disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, gitService, @@ -916,7 +917,7 @@ suite('AgentService (node dispatcher)', () => { test('createSession validates, exposes, persists, and inherits multi-root metadata', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); localService.registerProvider(agent); @@ -987,7 +988,7 @@ suite('AgentService (node dispatcher)', () => { } const agent = new RejectingFolderPickerAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(agent); const session = await localService.createSession({ @@ -1013,7 +1014,7 @@ suite('AgentService (node dispatcher)', () => { } const agent = new PinningFolderPickerAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(agent); const session = await localService.createSession({ @@ -1047,7 +1048,7 @@ suite('AgentService (node dispatcher)', () => { const db = new TestSessionDatabase(); // Create writes the frozen decision into the session DB (non-provisional). - const creating = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const creating = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const creatingAgent = new DecidingFolderPickerAgent('copilot'); creatingAgent.decision = decision; disposables.add(toDisposable(() => creatingAgent.dispose())); @@ -1059,7 +1060,7 @@ suite('AgentService (node dispatcher)', () => { // Reopen: a fresh service on the same DB rediscovers the provider-native // session and must restore the persisted decision into `_meta`. - const reopened = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const reopened = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); reopened.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const reopenedAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => reopenedAgent.dispose())); @@ -1103,7 +1104,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new TestSessionDatabase(); - const creating = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const creating = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new ProvisionalDecidingAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); creating.registerProvider(agent); @@ -1117,7 +1118,7 @@ suite('AgentService (node dispatcher)', () => { agent.materialize(session, [URI.file('/work/one'), URI.file('/work/two')]); await timeout(0); - const reopened = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const reopened = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); reopened.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const reopenedAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => reopenedAgent.dispose())); @@ -1158,7 +1159,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new ProvisionalAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); localService.registerProvider(agent); @@ -1208,7 +1209,7 @@ suite('AgentService (node dispatcher)', () => { test('reconciles pending worktree isolation when creating session config changes', async () => { const gitService = createNoopGitService(); const sessionDataService = createSessionDataService(new TestSessionDatabase()); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const isolation = disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, gitService, @@ -1360,7 +1361,7 @@ suite('AgentService (node dispatcher)', () => { return { provider: this.id, displayName: this.id, description: this.id, capabilities: { multipleWorkingDirectories: { immutablePrimary: true } } }; } } - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MultiRootMockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); localService.registerProvider(agent); @@ -1420,7 +1421,7 @@ suite('AgentService (node dispatcher)', () => { const repoA = URI.file('/workspace/repoA'); const showBlobCalls: Array<{ workingDirectory: string; ref: string; repoRelativePath: string }> = []; const gitService = createBlobGitService(new Map([[repoA.toString(), repoA]]), showBlobCalls); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MockAgent('copilot'); agent.sessionMetadataOverrides = { workingDirectories: [repoA] }; disposables.add(toDisposable(() => agent.dispose())); @@ -1448,7 +1449,7 @@ suite('AgentService (node dispatcher)', () => { const repoA = URI.file('/workspace/repoA'); const showBlobCalls: Array<{ workingDirectory: string; ref: string; repoRelativePath: string }> = []; const gitService = createBlobGitService(new Map([[repoA.toString(), repoA]]), showBlobCalls); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MockAgent('copilot'); agent.sessionMetadataOverrides = { workingDirectories: [repoA] }; disposables.add(toDisposable(() => agent.dispose())); @@ -1540,7 +1541,7 @@ suite('AgentService (node dispatcher)', () => { async function setupTitleGeneration(copilotApiService: TestCopilotApiService, activeAgentTitleGeneration = false): Promise<{ svc: AgentService; agent: MockAgent; session: URI; db: TestSessionDatabase }> { const db = new TestSessionDatabase(); const sessionDataService = createSessionDataService(db); - const svc = disposables.add(new AgentService( + const svc = disposables.add(createTestAgentService( new NullLogService(), fileService, sessionDataService, @@ -1583,7 +1584,7 @@ suite('AgentService (node dispatcher)', () => { } async function createDynamicWorkingDirectorySession(immutablePrimary = true): Promise<{ svc: AgentService; session: URI; primary: URI; secondary: URI }> { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new DynamicWorkingDirectoryAgent('dynamic', immutablePrimary); disposables.add(toDisposable(() => agent.dispose())); svc.registerProvider(agent); @@ -1598,7 +1599,7 @@ suite('AgentService (node dispatcher)', () => { } test('rejects a turn id already used by another chat before applying it', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + 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())); svc.registerProvider(agent); @@ -1642,7 +1643,7 @@ suite('AgentService (node dispatcher)', () => { }); test('rejects a turn id used by an unresolved restored peer before applying it', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + 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())); svc.registerProvider(agent); @@ -1712,7 +1713,7 @@ suite('AgentService (node dispatcher)', () => { }); test('rejects client writes to host-owned Agent Merge controller state', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + 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())); svc.registerProvider(agent); @@ -1740,7 +1741,7 @@ suite('AgentService (node dispatcher)', () => { }); test('preserves host-owned Agent Merge controller state across a client config replacement', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + 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())); svc.registerProvider(agent); @@ -1767,7 +1768,7 @@ suite('AgentService (node dispatcher)', () => { }); test('accepts client writes to the client-owned Agent Merge enablement value', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + 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())); svc.registerProvider(agent); @@ -1811,7 +1812,7 @@ suite('AgentService (node dispatcher)', () => { test('rejects a failed review update and clears the client dispatch queue', async () => { const db = new TestSessionDatabase(); db.getMetadata = async () => { throw new Error('metadata unavailable'); }; - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); svc.registerProvider(agent); @@ -2000,7 +2001,7 @@ suite('AgentService (node dispatcher)', () => { const localDisposables = new DisposableStore(); try { const rootConfigResource = joinPath(tempDir, 'agent-host-config.json'); - const svc = localDisposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), rootConfigResource)); + const svc = localDisposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), rootConfigResource)); const agent = new MockAgent('copilot'); localDisposables.add(toDisposable(() => agent.dispose())); svc.registerProvider(agent); @@ -2300,7 +2301,7 @@ suite('AgentService (node dispatcher)', () => { const logService = new class extends NullLogService { override warn(message: string): void { warnings.push(message); } }; - const svc = disposables.add(new AgentService(logService, fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(logService, fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); svc.registerProvider(agent); @@ -2742,7 +2743,7 @@ suite('AgentService (node dispatcher)', () => { test('retries a transient registry registration failure before reporting creation success', async () => { const db = new TransientRegistryWriteDatabase(); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); await svc.listSessions(); @@ -2783,7 +2784,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new FailingProviderDataDatabase(); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new BackedDefaultChatAgent('copilot')); svc.registerProvider(agent); @@ -2834,7 +2835,7 @@ suite('AgentService (node dispatcher)', () => { ...nullSessionDataService, deleteSessionData: async () => { order.push('deleteSessionData'); }, }; - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(copilotAgent); const session = await svc.createSession({ provider: 'copilot' }); const workingDirectoryPendingChange = disposables.add(new Emitter<string>()); @@ -2860,7 +2861,7 @@ suite('AgentService (node dispatcher)', () => { ...nullSessionDataService, deleteSessionData: async () => { deletedSessionData = true; }, }; - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(copilotAgent); const session = await svc.createSession({ provider: 'copilot' }); svc.setWorktreeIsolation({ @@ -2890,7 +2891,7 @@ suite('AgentService (node dispatcher)', () => { ...createSessionDataService(), deleteSessionData: async () => { deleteSessionDataCalls++; }, }; - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); const session = await svc.createSession({ provider: 'copilot' }); @@ -2964,8 +2965,8 @@ suite('AgentService (node dispatcher)', () => { } } - function createExternalSessionService(now: () => number, sessionDataService = createSessionDataService(), orchestratorDatabase?: IAgentHostDatabase): AgentService { - return disposables.add(new AgentService( + function createExternalSessionService(sessionDataService = createSessionDataService(), orchestratorDatabase?: IAgentHostDatabase): AgentService { + return disposables.add(createTestAgentService( new NullLogService(), fileService, sessionDataService, @@ -2980,10 +2981,17 @@ suite('AgentService (node dispatcher)', () => { undefined, undefined, orchestratorDatabase, - now, )); } + function testWithExternalSessionClock(name: string, fn: () => Promise<void>): void { + test(name, () => runWithFakedTimers({ + useFakeTimers: true, + startTime: Date.UTC(2026, 0, 1), + maxTaskCount: 10_000, + }, fn)); + } + function setExternalSessionsMode(service: AgentService, mode: AgentHostExternalSessionsMode, clientSeq: number): void { service.dispatchAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, @@ -3029,7 +3037,7 @@ suite('AgentService (node dispatcher)', () => { test('listSessions discovers provider-native sessions as external and restore preserves provenance', async () => { const db = new TestSessionDatabase(); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); @@ -3055,7 +3063,7 @@ suite('AgentService (node dispatcher)', () => { test('rediscovery does not overwrite durable unread state for an existing external session', async () => { const db = new TestSessionDatabase(); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); const session = AgentSession.uri('copilot', 'rediscovered-external'); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(AgentSession.id(session), session); @@ -3068,10 +3076,10 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(await db.getMetadata(AH_META_IS_READ_DB_KEY), ''); }); - test('discovery does not ingest external sessions older than 30 days', async () => { + testWithExternalSessionClock('discovery does not ingest external sessions older than 30 days', async () => { const day = 24 * 60 * 60 * 1000; const now = Date.now(); - const svc = createExternalSessionService(() => now); + const svc = createExternalSessionService(); const agent = disposables.add(new TimedExternalAgent('copilot')); const stale = agent.addSession('stale', now - 30 * day - 1); const fresh = agent.addSession('fresh', now - 30 * day + 60_000); @@ -3096,14 +3104,14 @@ suite('AgentService (node dispatcher)', () => { assert.ok(!registered.has(stale.toString())); }); - test('prune removes stale external sessions but keeps adoptable-legacy sessions', async () => { + testWithExternalSessionClock('prune removes stale external sessions but keeps adoptable-legacy sessions', async () => { const day = 24 * 60 * 60 * 1000; const now = Date.now(); - const svc = createExternalSessionService(() => now); + const svc = createExternalSessionService(); const agent = disposables.add(new TimedExternalAgent('copilot')); const stale = agent.addSession('stale-prune', now - 30 * day - 1); const staleAdoptable = agent.addSession('stale-adoptable', now - 30 * day - 1, withSessionEhcliAdoptable(undefined)); - const fresh = agent.addSession('fresh-prune', now - 30 * day); + const fresh = agent.addSession('fresh-prune', now - 29 * day); svc.registerProvider(agent); const sessionRegistry = (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry; await sessionRegistry.register(stale, { provider: 'copilot', startTime: now - 30 * day - 1, source: 'discovery' }, { checkTombstone: true }); @@ -3116,17 +3124,48 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(registered, [fresh.toString(), staleAdoptable.toString()].sort()); }); - test('filters external sessions in every mode with inclusive time boundaries', async () => { + test('external session mode time boundaries are inclusive', () => { + const day = 24 * 60 * 60 * 1000; + const now = Date.UTC(2026, 0, 1); + const svc = createExternalSessionService(); + const shouldIncludeSession = (svc as unknown as { + _shouldIncludeSession(session: IAgentSessionMetadata, mode: AgentHostExternalSessionsMode, now: number): boolean; + })._shouldIncludeSession.bind(svc); + const metadata = (age: number): IAgentSessionMetadata => ({ + session: AgentSession.uri('copilot', `age-${age}`), + startTime: now - age, + modifiedTime: now - age, + _meta: withSessionExternal(undefined, true), + }); + + assert.deepStrictEqual({ + at24Hours: shouldIncludeSession(metadata(day), AgentHostExternalSessionsMode.Last24Hours, now), + olderThan24Hours: shouldIncludeSession(metadata(day + 1), AgentHostExternalSessionsMode.Last24Hours, now), + at7Days: shouldIncludeSession(metadata(7 * day), AgentHostExternalSessionsMode.Last7Days, now), + olderThan7Days: shouldIncludeSession(metadata(7 * day + 1), AgentHostExternalSessionsMode.Last7Days, now), + at30Days: shouldIncludeSession(metadata(30 * day), AgentHostExternalSessionsMode.Last30Days, now), + olderThan30Days: shouldIncludeSession(metadata(30 * day + 1), AgentHostExternalSessionsMode.Last30Days, now), + }, { + at24Hours: true, + olderThan24Hours: false, + at7Days: true, + olderThan7Days: false, + at30Days: true, + olderThan30Days: false, + }); + }); + + testWithExternalSessionClock('filters external sessions in every mode', async () => { const day = 24 * 60 * 60 * 1000; const now = Date.now(); - const svc = createExternalSessionService(() => now); + const svc = createExternalSessionService(); const agent = disposables.add(new TimedExternalAgent('copilot')); agent.addSession('recent', now); - agent.addSession('at-24-hours', now - day); + agent.addSession('within-24-hours', now - day + day / 2); agent.addSession('older-than-24-hours', now - day - 1); - agent.addSession('at-7-days', now - 7 * day); + agent.addSession('within-7-days', now - 7 * day + day / 2); agent.addSession('older-than-7-days', now - 7 * day - 1); - agent.addSession('at-30-days', now - 30 * day); + agent.addSession('within-30-days', now - 30 * day + day / 2); agent.addSession('older-than-30-days', now - 30 * day - 1); svc.registerProvider(agent); @@ -3148,19 +3187,19 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ listedByDefault, listedByMode }, { listedByDefault: [], listedByMode: { - [AgentHostExternalSessionsMode.Recent]: ['at-24-hours', 'recent'], + [AgentHostExternalSessionsMode.Recent]: ['recent', 'within-24-hours'], [AgentHostExternalSessionsMode.None]: [], - [AgentHostExternalSessionsMode.Last30Days]: ['at-24-hours', 'at-30-days', 'at-7-days', 'older-than-24-hours', 'older-than-7-days', 'recent'], - [AgentHostExternalSessionsMode.Last24Hours]: ['at-24-hours', 'recent'], - [AgentHostExternalSessionsMode.Last7Days]: ['at-24-hours', 'at-7-days', 'older-than-24-hours', 'recent'], + [AgentHostExternalSessionsMode.Last30Days]: ['older-than-24-hours', 'older-than-7-days', 'recent', 'within-24-hours', 'within-30-days', 'within-7-days'], + [AgentHostExternalSessionsMode.Last24Hours]: ['recent', 'within-24-hours'], + [AgentHostExternalSessionsMode.Last7Days]: ['older-than-24-hours', 'recent', 'within-24-hours', 'within-7-days'], }, }); }); - test('a mode that hides every external session skips the catalog work for them', async () => { + testWithExternalSessionClock('a mode that hides every external session skips the catalog work for them', async () => { const now = Date.now(); const perSession = createPerSessionDataService(); - const svc = createExternalSessionService(() => now, perSession.service); + const svc = createExternalSessionService(perSession.service); const agent = disposables.add(new TimedExternalAgent('copilot')); agent.addSession('external-one', now); agent.addSession('external-two', now); @@ -3193,10 +3232,10 @@ suite('AgentService (node dispatcher)', () => { } }); - test('a mode change reconciles with a single catalog pass', async () => { + testWithExternalSessionClock('a mode change reconciles with a single catalog pass', async () => { const day = 24 * 60 * 60 * 1000; const now = Date.now(); - const svc = createExternalSessionService(() => now); + const svc = createExternalSessionService(); const agent = disposables.add(new TimedExternalAgent('copilot')); agent.addSession('recent', now); agent.addSession('yesterday', now - day); @@ -3233,9 +3272,9 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('recent replaces the oldest visible external session when a newer session is discovered', async () => { + testWithExternalSessionClock('recent replaces the oldest visible external session when a newer session is discovered', async () => { const now = Date.now(); - const svc = createExternalSessionService(() => now); + const svc = createExternalSessionService(); setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); await waitForSessionListReconciliation(svc); const agent = disposables.add(new TimedExternalAgent('copilot')); @@ -3272,7 +3311,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('recent re-adds a registry-known external session after restart list visibility rotates', async () => { + testWithExternalSessionClock('recent re-adds a registry-known external session after restart list visibility rotates', async () => { const now = Date.now(); const database = new TransientRegistryWriteDatabase(); const first = AgentSession.uri('copilot', 'first'); @@ -3283,7 +3322,7 @@ suite('AgentService (node dispatcher)', () => { } await database.markProviderBackfilled('copilot'); - const svc = createExternalSessionService(() => now, createSessionDataService(), database); + const svc = createExternalSessionService(createSessionDataService(), database); setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); await waitForSessionListReconciliation(svc); const agent = disposables.add(new TimedExternalAgent('copilot')); @@ -3321,9 +3360,9 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('external discovery reconciles against a mode change that completes while registration is in flight', async () => { + testWithExternalSessionClock('external discovery reconciles against a mode change that completes while registration is in flight', async () => { const now = Date.now(); - const svc = createExternalSessionService(() => now); + const svc = createExternalSessionService(); setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); await waitForSessionListReconciliation(svc); const agent = disposables.add(new TimedExternalAgent('copilot')); @@ -3373,9 +3412,9 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('recent reconciles clients when a hidden external session becomes more recent', async () => { + testWithExternalSessionClock('recent reconciles clients when a hidden external session becomes more recent', async () => { const now = Date.now(); - const svc = createExternalSessionService(() => now); + const svc = createExternalSessionService(); setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); await waitForSessionListReconciliation(svc); const agent = disposables.add(new TimedExternalAgent('copilot')); @@ -3414,9 +3453,9 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('configuration changes add and remove non-live external sessions immediately', async () => { + testWithExternalSessionClock('configuration changes add and remove non-live external sessions immediately', async () => { const now = Date.now(); - const svc = createExternalSessionService(() => now); + const svc = createExternalSessionService(); const agent = disposables.add(new TimedExternalAgent('copilot')); const session = agent.addSession('config-visible', now); const notifications: string[] = []; @@ -3444,9 +3483,9 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(notifications, [`add:${session.toString()}`, `remove:${session.toString()}`]); }); - test('unpublishes and republishes a restored external session as the configured mode changes', async () => { + testWithExternalSessionClock('unpublishes and republishes a restored external session as the configured mode changes', async () => { const now = Date.now(); - const svc = createExternalSessionService(() => now); + const svc = createExternalSessionService(); setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); await waitForSessionListReconciliation(svc); const agent = disposables.add(new TimedExternalAgent('copilot')); @@ -3481,7 +3520,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('publishes an external session restored while hidden when the configured mode includes it', async () => { + testWithExternalSessionClock('publishes an external session restored while hidden when the configured mode includes it', async () => { class ExternalOnlyAgent extends TimedExternalAgent { override async listSessions(): Promise<IAgentSessionMetadata[]> { return []; @@ -3489,7 +3528,7 @@ suite('AgentService (node dispatcher)', () => { } const now = Date.now(); - const svc = createExternalSessionService(() => now); + const svc = createExternalSessionService(); const agent = disposables.add(new ExternalOnlyAgent('copilot')); const session = agent.addSession('hidden-then-restored', now); const notifications: string[] = []; @@ -3523,7 +3562,7 @@ suite('AgentService (node dispatcher)', () => { }); test('discovery registration preserves provider-supplied internal provenance', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); await svc.listSessions(); @@ -3545,7 +3584,7 @@ suite('AgentService (node dispatcher)', () => { }); test('discovery announces a registered session with provider metadata intact', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); @@ -3576,7 +3615,7 @@ suite('AgentService (node dispatcher)', () => { }); test('an adoptable chat retracted by disabling migration is re-surfaced when it is re-enabled', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); @@ -3609,7 +3648,7 @@ suite('AgentService (node dispatcher)', () => { test('rediscovering a registered chat with different provenance performs no per-session database I/O', async () => { const perSession = createPerSessionDataService(); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); const session = AgentSession.uri('copilot', 'known-discovered'); @@ -3633,7 +3672,7 @@ suite('AgentService (node dispatcher)', () => { }); test('the known-sessions filter reports registered sessions only, leaving tombstones to registration', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); const registered = AgentSession.uri('copilot', 'filter-registered'); @@ -3658,7 +3697,7 @@ suite('AgentService (node dispatcher)', () => { }); test('concurrent listSessions calls share one computation and never share their result array', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); await svc.createSession({ provider: 'copilot' }); @@ -3692,7 +3731,7 @@ suite('AgentService (node dispatcher)', () => { }); test('a registry mutation during an in-flight list is not served from the shared computation', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); await svc.listSessions(); @@ -3727,7 +3766,7 @@ suite('AgentService (node dispatcher)', () => { }); test('provider registration invalidates an in-flight list computation', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const gate = new DeferredPromise<void>(); const inner = svc as unknown as { _computeSessions(mode: AgentHostExternalSessionsMode): Promise<readonly IAgentSessionMetadata[]> }; const original = inner._computeSessions; @@ -3790,7 +3829,7 @@ suite('AgentService (node dispatcher)', () => { const legacy = AgentSession.uri('copilot', 'legacy-catalog'); const sessionData = createPerSessionDataService(); await sessionData.database(legacy).setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'false'); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new SeparateCatalogAgent('copilot')); svc.registerProvider(agent); await svc.listSessions(); @@ -3819,7 +3858,7 @@ suite('AgentService (node dispatcher)', () => { }); test('one invalid discovered chat does not block sibling registration', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); await svc.listSessions(); @@ -3843,7 +3882,7 @@ suite('AgentService (node dispatcher)', () => { }); test('failed discovery announcement releases its deduplication reservation', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); await svc.listSessions(); @@ -3887,7 +3926,7 @@ suite('AgentService (node dispatcher)', () => { const external = AgentSession.uri('copilot', 'migration-external'); const sessionData = createPerSessionDataService(); await sessionData.database(restored).setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(disposables.add(new MixedMigrationAgent('copilot'))); await svc.listSessions(); @@ -3910,7 +3949,7 @@ suite('AgentService (node dispatcher)', () => { database.addSessionWithoutExternal({ session: external.toString(), provider: 'claude', startTime: 2, external: false, source: 'explicit' }); const sessionData = createPerSessionDataService(); await sessionData.database(internal).setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'false'); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database)); await svc.getRegisteredSessions(); await svc.getRegisteredSessions(); @@ -3954,7 +3993,7 @@ suite('AgentService (node dispatcher)', () => { legacyDatabase = undefined; database = new AgentHostDatabase(path); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database)); const agent = disposables.add(new MockAgent('copilot')); const session = AgentSession.uri('copilot', 'legacy-real-database'); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(AgentSession.id(session), session); @@ -3981,7 +4020,7 @@ suite('AgentService (node dispatcher)', () => { return super.listExternalChats(); } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new CountingAgent('copilot')); const native = AgentSession.uri('copilot', 'native-disappeared'); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(AgentSession.id(native), native); @@ -4012,7 +4051,7 @@ suite('AgentService (node dispatcher)', () => { return super.listExternalChats(); } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new GatedListAgent('copilot')); svc.registerProvider(agent); @@ -4062,7 +4101,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new TransientListFailureAgent('copilot')); svc.registerProvider(agent); @@ -4083,7 +4122,7 @@ suite('AgentService (node dispatcher)', () => { }); test('a late-registered provider gets its own native discovery pass', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const early = disposables.add(new MockAgent('copilot')); svc.registerProvider(early); @@ -4126,7 +4165,7 @@ suite('AgentService (node dispatcher)', () => { } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new LateEnumerableAgent('copilot')); svc.registerProvider(agent); @@ -4159,7 +4198,7 @@ suite('AgentService (node dispatcher)', () => { return undefined; } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new AdoptableLegacyAgent('copilot')); const legacy = AgentSession.uri('copilot', 'adoptable-legacy'); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(AgentSession.id(legacy), legacy); @@ -4181,7 +4220,7 @@ suite('AgentService (node dispatcher)', () => { }); test('does not surface a discovered session that was already deleted', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); const legacy = AgentSession.uri('copilot', 'deleted-adoptable-legacy'); svc.registerProvider(agent); @@ -4221,7 +4260,7 @@ suite('AgentService (node dispatcher)', () => { super.dispose(); } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const providerA = disposables.add(new CountingAgent('copilot')); const providerB = disposables.add(new FailingThenRecoveringAgent('other')); @@ -4272,7 +4311,7 @@ suite('AgentService (node dispatcher)', () => { super.dispose(); } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new NotYetEnumerableAgent('copilot')); const originalListExternalChats = agent.listExternalChats.bind(agent); @@ -4310,7 +4349,7 @@ suite('AgentService (node dispatcher)', () => { const existing = AgentSession.uri('copilot', 'existing-before-unavailable'); await db.registerSession(existing.toString(), { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); const writesBeforeUnavailable = db.registryWriteAttempts; - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new NotYetMigratableAgent('copilot')); const legacy = AgentSession.uri('copilot', 'legacy-migration-not-ready'); @@ -4370,7 +4409,7 @@ suite('AgentService (node dispatcher)', () => { return []; } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new SingleFlightRetryAgent('copilot')); svc.registerProvider(agent); for (let i = 0; i < 20 && agent.catalogCalls === 0; i++) { @@ -4400,7 +4439,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TransientRegistryWriteDatabase(); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); const copilot = disposables.add(new CatalogAgent('copilot')); @@ -4465,7 +4504,7 @@ suite('AgentService (node dispatcher)', () => { super.dispose(); } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new GatedListAgent('copilot')); svc.registerProvider(agent); @@ -4508,7 +4547,7 @@ suite('AgentService (node dispatcher)', () => { // downgrade to pre-per-provider code reading a prematurely-set // global marker would then silently skip that late provider's // legacy sessions forever. - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const early = disposables.add(new MockAgent('copilot')); svc.registerProvider(early); await svc.listSessions(); @@ -4539,7 +4578,7 @@ suite('AgentService (node dispatcher)', () => { super.dispose(); } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new ChatListChangeAgent('copilot')); svc.registerProvider(agent); @@ -4567,7 +4606,7 @@ suite('AgentService (node dispatcher)', () => { }); test('an explicit create at a previously-deleted session URI clears its tombstone and allows reuse', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); const reusedUri = AgentSession.uri('copilot', 'reused-after-delete'); @@ -4614,7 +4653,7 @@ suite('AgentService (node dispatcher)', () => { super.dispose(); } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new GatedListAgent('copilot')); svc.registerProvider(agent); await svc.listSessions(); @@ -4654,7 +4693,7 @@ suite('AgentService (node dispatcher)', () => { const db = new TransientRegistryWriteDatabase(); // Simulate an old database whose legacy one-time marker is set. await db.markSessionRegistryBackfilled(); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new CountingAgent('copilot')); const legacy = AgentSession.uri('copilot', 'old-db-native-session'); @@ -4697,7 +4736,7 @@ suite('AgentService (node dispatcher)', () => { super.dispose(); } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new SequentiallyGatedListAgent('copilot')); svc.registerProvider(agent); @@ -4757,7 +4796,7 @@ suite('AgentService (node dispatcher)', () => { super.dispose(); } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new SequentiallyGatedListAgent('copilot')); svc.registerProvider(agent); @@ -4797,7 +4836,7 @@ suite('AgentService (node dispatcher)', () => { return this.dropFromList ? undefined : super.getSessionMetadata(session); } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new FlakyListAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); svc.registerProvider(agent); @@ -4814,7 +4853,7 @@ suite('AgentService (node dispatcher)', () => { }); test('session registry stays in parity with listSessions across create/delete', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); svc.registerProvider(agent); @@ -4868,7 +4907,7 @@ suite('AgentService (node dispatcher)', () => { // Manually add the session to the mock (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); @@ -4914,7 +4953,7 @@ suite('AgentService (node dispatcher)', () => { }; (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); @@ -4937,7 +4976,7 @@ suite('AgentService (node dispatcher)', () => { _meta: { multiRoot: { workspaceFile: 'file:///provider-spoof.code-workspace' } }, }; (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); @@ -4958,7 +4997,7 @@ suite('AgentService (node dispatcher)', () => { const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); @@ -4976,7 +5015,7 @@ suite('AgentService (node dispatcher)', () => { _meta: { multiRoot: { workspaceFile: 'file:///provider-spoof.code-workspace' } }, }; (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); @@ -5002,7 +5041,7 @@ suite('AgentService (node dispatcher)', () => { worktreeRootResolutions++; return []; }; - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, gitService)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, gitService)); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); @@ -5037,7 +5076,7 @@ suite('AgentService (node dispatcher)', () => { const gitService = createNoopGitService(); gitService.getWorktreeRoots = async () => [primaryRoot, linkedCheckout, sessionWorktree]; const sessionDataService = createSessionDataService(db); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.setWorktreeIsolation(disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, @@ -5084,7 +5123,7 @@ suite('AgentService (node dispatcher)', () => { gitService.getCurrentBranch = async () => undefined; gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' }); const sessionDataService = createSessionDataService(db); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.setWorktreeIsolation(disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, @@ -5317,7 +5356,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -5360,7 +5399,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -5396,7 +5435,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -5451,7 +5490,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(agent); // Seed live changeset state directly: a single file with @@ -5520,7 +5559,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(agent); // Seed a ready (zero-file) live changeset state — this alone @@ -5563,7 +5602,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(agent); // Register a changeset but leave it in the default @@ -5653,7 +5692,7 @@ suite('AgentService (node dispatcher)', () => { getBranchDiffSafetyInfo: async () => undefined, getDiffPatchBetweenRefs: async () => undefined, }; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; @@ -5700,7 +5739,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDb = new SessionDatabase(':memory:'); disposables.add(toDisposable(() => sessionDb.close())); const sessionDataService = createSessionDataService(sessionDb); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; @@ -5760,7 +5799,7 @@ suite('AgentService (node dispatcher)', () => { getBranchDiffSafetyInfo: async () => undefined, getDiffPatchBetweenRefs: async () => undefined, }; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); // No resolvedWorkingDirectory set on the mock. @@ -5784,7 +5823,7 @@ suite('AgentService (node dispatcher)', () => { // Probe runs but reports "not a git repo". gitService.getSessionGitState = async () => undefined; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; @@ -5815,7 +5854,7 @@ suite('AgentService (node dispatcher)', () => { const gitService = createNoopGitService(); gitService.getSessionGitState = async () => gitState; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; @@ -5849,7 +5888,7 @@ suite('AgentService (node dispatcher)', () => { const gitService = createNoopGitService(); gitService.getSessionGitState = async () => gitState; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; @@ -5894,7 +5933,7 @@ suite('AgentService (node dispatcher)', () => { const calls: string[] = []; const gitService = createNoopGitService(); gitService.getSessionGitState = async (uri: URI) => { calls.push(uri.fsPath); return gitState; }; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; @@ -5947,7 +5986,7 @@ suite('AgentService (node dispatcher)', () => { test('annotations survive session state restoration', async () => { const sessionData = createPerSessionDataService(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); localService.registerProvider(agent); @@ -5975,7 +6014,7 @@ suite('AgentService (node dispatcher)', () => { test('annotations subscribe concurrent with session restore returns persisted feedback', async () => { const sessionData = createPerSessionDataService(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); localService.registerProvider(agent); @@ -6008,7 +6047,7 @@ suite('AgentService (node dispatcher)', () => { test('subagent annotations persist in the parent session database', async () => { const sessionData = createPerSessionDataService(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); localService.registerProvider(agent); @@ -6528,7 +6567,7 @@ suite('AgentService (node dispatcher)', () => { } } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new DelayedMigrationAgent('copilot')); const { session } = await createAgentSession(agent); svc.registerProvider(agent); @@ -6553,7 +6592,7 @@ suite('AgentService (node dispatcher)', () => { }); test('rejects restoring a session that has been explicitly deleted (tombstoned) without resurrecting it', async () => { - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); @@ -6597,7 +6636,7 @@ suite('AgentService (node dispatcher)', () => { } function makeService(): AgentService { - return disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + return disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); } function seedSession(agent: MockAgent, session: URI): void { @@ -6721,7 +6760,7 @@ suite('AgentService (node dispatcher)', () => { const session = AgentSession.uri('copilot', 'registered-but-unavailable'); await db.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); await db.markProviderBackfilled('copilot'); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); const agent = disposables.add(new StartupRaceAgent('copilot')); agent.migrationGate.complete(); svc.registerProvider(agent); @@ -6774,7 +6813,7 @@ suite('AgentService (node dispatcher)', () => { // restore from the central session DB — the agent (MockAgent) re-emits // nothing itself, yet the restored session still carries the tag. const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); await createAgentSession(copilotAgent); const sessionResource = (await copilotAgent.listSessions())[0].session; @@ -6788,7 +6827,7 @@ suite('AgentService (node dispatcher)', () => { test('restores persisted multi-root metadata', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); await createAgentSession(copilotAgent); const sessionResource = (await copilotAgent.listSessions())[0].session; @@ -6808,7 +6847,7 @@ suite('AgentService (node dispatcher)', () => { test('restores persisted orchestration metadata', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); await createAgentSession(copilotAgent); const sessionResource = (await copilotAgent.listSessions())[0].session; @@ -6828,7 +6867,7 @@ suite('AgentService (node dispatcher)', () => { test('does not consume a child notification when its creator cannot be resolved', async () => { const sessionData = createPerSessionDataService(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); const child = await localService.createSession({ provider: 'copilot' }); const orchestration: ISessionOrchestration = { @@ -6853,7 +6892,7 @@ suite('AgentService (node dispatcher)', () => { test('restores a cold creator before delivering and consuming a child notification', async () => { const sessionData = createPerSessionDataService(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); const creator = await localService.createSession({ provider: 'copilot' }); const child = await localService.createSession({ provider: 'copilot' }); @@ -6892,7 +6931,7 @@ suite('AgentService (node dispatcher)', () => { test('restores persisted source-control provenance', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); await createAgentSession(copilotAgent); const sessionResource = (await copilotAgent.listSessions())[0].session; @@ -6951,7 +6990,7 @@ suite('AgentService (node dispatcher)', () => { // the host-side overlay a reloaded session comes back with no // context-usage gauge and a session cost of 0. const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); const { session } = await createAgentSession(copilotAgent); const sessionResource = (await copilotAgent.listSessions())[0].session; @@ -6976,7 +7015,7 @@ suite('AgentService (node dispatcher)', () => { // not. Treating that stub as "already has usage" would skip exactly // the turns needing re-attachment — and Auto is the default model. const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const autoModeResolved = { chosenModel: 'claude-opus-4.8', predictedLabel: 'needs_reasoning', confidence: 0.93 }; const agent = disposables.add(new MockAgent('copilot')); agent.turnUsageOverride = { model: 'claude-opus-4.8', _meta: { autoModeResolved } }; @@ -7005,7 +7044,7 @@ suite('AgentService (node dispatcher)', () => { test('interleaves persisted host-injected local turns after their anchor on restore', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); const { session } = await createAgentSession(copilotAgent); const sessionResource = (await copilotAgent.listSessions())[0].session; @@ -7036,7 +7075,7 @@ suite('AgentService (node dispatcher)', () => { test('restores the default chat\'s independently-renamed title', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); await createAgentSession(copilotAgent); const sessionResource = (await copilotAgent.listSessions())[0].session; @@ -7055,7 +7094,7 @@ suite('AgentService (node dispatcher)', () => { test('persists chat drafts to session metadata', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); const session = await localService.createSession({ provider: 'copilot' }); const draft = { @@ -7075,7 +7114,7 @@ suite('AgentService (node dispatcher)', () => { test('restores chat drafts from session metadata', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); const { session } = await createAgentSession(copilotAgent); const sessionResource = (await copilotAgent.listSessions())[0].session; @@ -7202,7 +7241,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new AdoptOnOpenAgent()); localService.registerProvider(agent); agent.sessionMessages = []; @@ -7245,7 +7284,7 @@ suite('AgentService (node dispatcher)', () => { } } - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(disposables.add(new NotAdoptableAgent())); localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); @@ -7272,7 +7311,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new AdoptOnOpenAgent()); localService.registerProvider(agent); agent.sessionMessages = []; @@ -7308,7 +7347,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new AdoptOnOpenAgent()); localService.registerProvider(agent); @@ -7345,7 +7384,7 @@ suite('AgentService (node dispatcher)', () => { test('excludes adoptable-legacy sessions from the list while the migrate setting is off', async () => { // Guards against a refresh re-surfacing a registry entry that can no longer be opened while migration is off. - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const adoptable: IAgentSessionMetadata = { session: AgentSession.uri('copilot', 'adoptable-list-gate'), startTime: Date.now(), @@ -7739,7 +7778,7 @@ suite('AgentService (node dispatcher)', () => { test('legacy subagent reconstruction restores a persisted custom title', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); const parent = await localService.createSession({ provider: 'copilot' }); const childChat = buildSubagentChatUri(parent.toString(), 'tc-sub'); @@ -8153,7 +8192,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -8275,7 +8314,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new TestSessionDatabase(); const agent = disposables.add(new MultiChatAgent('copilot')); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(agent); const { session } = await createAgentSession(agent); const sessionResource = (await agent.listSessions())[0].session; @@ -8354,7 +8393,7 @@ suite('AgentService (node dispatcher)', () => { } const agent = disposables.add(new LeakyMultiChatAgent('copilot')); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(agent); const session = await svc.createSession({ provider: 'copilot' }); const chatUri = URI.parse(buildChatUri(session, 'peer-1')); @@ -8365,7 +8404,7 @@ suite('AgentService (node dispatcher)', () => { // Simulate a host restart: a fresh service over the same persisted // databases, with a fresh agent still leaking the backing session. const restartAgent = disposables.add(new LeakyMultiChatAgent('copilot')); - const restarted = disposables.add(new AgentService(new NullLogService(), fileService, perSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const restarted = disposables.add(createTestAgentService(new NullLogService(), fileService, perSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); restarted.registerProvider(restartAgent); const afterRestart = await restarted.listSessions(); @@ -8383,7 +8422,7 @@ suite('AgentService (node dispatcher)', () => { test('createSession carries client-owned _meta slots and drops unknown ones', async () => { const perSession = createPerSessionDataService(); const agent = disposables.add(new MockAgent('copilot')); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(agent); const session = await svc.createSession({ @@ -8411,7 +8450,7 @@ suite('AgentService (node dispatcher)', () => { test('ephemeral session teardown clears its discovery tombstone', async () => { const perSession = createPerSessionDataService(); const agent = disposables.add(new MockAgent('copilot')); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(agent); const registry = (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry; @@ -8445,7 +8484,7 @@ suite('AgentService (node dispatcher)', () => { } const agent = disposables.add(new LeakyAgent('copilot')); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); svc.registerProvider(agent); await svc.createSession({ provider: 'copilot', @@ -8462,7 +8501,7 @@ suite('AgentService (node dispatcher)', () => { const registeredBeforeRestart = await svc.getRegisteredSessions(); const restartedAgent = disposables.add(new LeakyAgent('copilot')); - const restarted = disposables.add(new AgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const restarted = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); restarted.registerProvider(restartedAgent); const afterRestart = await restarted.listSessions(); @@ -8504,7 +8543,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new FailingBackingMarkerDatabase(); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new BackedMultiChatAgent('copilot')); svc.registerProvider(agent); const session = await svc.createSession({ provider: 'copilot' }); @@ -8552,7 +8591,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new FailingBackingMarkerDatabase(); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new BackedMultiChatAgent('copilot')); svc.registerProvider(agent); const session = await svc.createSession({ provider: 'copilot' }); @@ -8685,7 +8724,7 @@ suite('AgentService (node dispatcher)', () => { test('creates a side chat from a completed local turn without losing its stable source turn identity', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new SideChatAgent('copilot')); localService.registerProvider(agent); const { session } = await createAgentSession(agent); @@ -8801,7 +8840,7 @@ suite('AgentService (node dispatcher)', () => { test('persists and restores the SideChat origin', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new SideChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -8840,7 +8879,7 @@ suite('AgentService (node dispatcher)', () => { test('resolves a restored peer side-chat source without resolving the target chat', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new SideChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -8874,7 +8913,7 @@ suite('AgentService (node dispatcher)', () => { test('hydrates a missing peer chat when resolving a generic Chat attachment', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new SideChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -9009,7 +9048,7 @@ suite('AgentService (node dispatcher)', () => { test('collapsed session creation persists and restores exact default-chat provider data', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const calls: { op: string; providerData?: string }[] = []; class ExactDefaultChatAgent extends MockAgent { override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ @@ -9077,7 +9116,7 @@ suite('AgentService (node dispatcher)', () => { test('host-restore-slice: restoring a legacy default chat recovers before canonical materialization and persists additively', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new RecoveringDefaultChatAgent('copilot')); localService.registerProvider(agent); @@ -9124,7 +9163,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new ExternalRestoreAgent('copilot')); const session = AgentSession.uri('copilot', 'external-restore'); (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(AgentSession.id(session), session); @@ -9149,7 +9188,7 @@ suite('AgentService (node dispatcher)', () => { test('host-restore-slice: a second restore reads the recovered providerData directly and never re-recovers or re-persists it', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new RecoveringDefaultChatAgent('copilot')); localService.registerProvider(agent); @@ -9179,7 +9218,7 @@ suite('AgentService (node dispatcher)', () => { test('host-restore-slice: a canonical default-chat providerData blob is never rewritten by a recovered materializeChat result', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new RecoveringDefaultChatAgent('copilot')); localService.registerProvider(agent); @@ -9206,7 +9245,7 @@ suite('AgentService (node dispatcher)', () => { test('host-restore-slice: a default chat with neither a persisted nor a recovered backing restores its history without binding anything', async () => { const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); // The base mock has no `materializeChat` at all, so restore has // nothing to re-attach and no bind fallback to reach for. const agent = disposables.add(new MockAgent('copilot')); @@ -9272,7 +9311,7 @@ suite('AgentService (node dispatcher)', () => { // A session data service that cannot open a database makes the // default-chat backing write — the last step of provisioning — // throw, which is what drives the create-time rollback. - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createNullSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createNullSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); class BackingChatSurfaceAgent extends ChatSurfaceAgent { override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ createChat: async (chat, context, options) => { @@ -9752,7 +9791,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new FailingPeerCatalogDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); class MultiChatAgent extends MockAgent { readonly disposedPeers: URI[] = []; override async createChat(): Promise<IAgentCreateChatResult> { @@ -9794,7 +9833,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new BackedPeerChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -9847,7 +9886,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -9928,7 +9967,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -9987,7 +10026,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10059,7 +10098,7 @@ suite('AgentService (node dispatcher)', () => { } } - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new ServerToolAgent('copilot')); localService.registerProvider(agent); const sourceSession = await localService.createSession({ provider: 'copilot' }); @@ -10136,7 +10175,7 @@ suite('AgentService (node dispatcher)', () => { })); } - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new ServerToolAgent('copilot')); localService.registerProvider(agent); const sourceSession = await localService.createSession({ provider: 'copilot' }); @@ -10195,7 +10234,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10244,7 +10283,7 @@ suite('AgentService (node dispatcher)', () => { override async getSessionMessages(): Promise<readonly Turn[]> { return []; } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10287,7 +10326,7 @@ suite('AgentService (node dispatcher)', () => { override async getSessionMessages(): Promise<readonly Turn[]> { return []; } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10328,7 +10367,7 @@ suite('AgentService (node dispatcher)', () => { override async getSessionMessages(): Promise<readonly Turn[]> { return []; } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10374,7 +10413,7 @@ suite('AgentService (node dispatcher)', () => { return []; } } - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new RestoringAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10418,7 +10457,7 @@ suite('AgentService (node dispatcher)', () => { return []; } } - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new RestoringAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ @@ -10468,7 +10507,7 @@ suite('AgentService (node dispatcher)', () => { override async getSessionMessages(): Promise<readonly Turn[]> { return []; } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = AgentSession.uri('copilot', 'reused-session'); @@ -10520,7 +10559,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10567,7 +10606,7 @@ suite('AgentService (node dispatcher)', () => { override async disposeChat(_session: URI, _chat: URI): Promise<void> { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10607,7 +10646,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new UpdatingDisposeAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10642,7 +10681,7 @@ suite('AgentService (node dispatcher)', () => { override async disposeChat(): Promise<void> { } } const db = new FailingRemovalDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10697,7 +10736,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new LegacyAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10745,7 +10784,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new LegacyAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10778,7 +10817,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new LegacyAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10816,7 +10855,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new LegacyAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10860,7 +10899,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new FailingCatalogDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new LegacyAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -10909,7 +10948,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new RecordingTitleDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.configurationService.updateRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: true }); const agent = disposables.add(new ServerToolAgent('copilot')); localService.registerProvider(agent); @@ -10996,7 +11035,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new FailingTitleDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.configurationService.updateRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: true }); const agent = disposables.add(new ServerToolAgent('copilot')); localService.registerProvider(agent); @@ -11176,7 +11215,7 @@ suite('AgentService (node dispatcher)', () => { await whenIdle.p; } } - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new DelayedIdleDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new DelayedIdleDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); localService.registerProvider(agent); @@ -11634,7 +11673,7 @@ suite('AgentService (node dispatcher)', () => { }; const sessionDataService = createSessionDataService(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); localService.registerProvider(copilotAgent); const sessionResource = await localService.createSession({ provider: 'copilot' }); const uncommittedUri = URI.parse(buildUncommittedChangesetUri(sessionResource.toString())); @@ -11675,7 +11714,7 @@ suite('AgentService (node dispatcher)', () => { }; const sessionDataService = createSessionDataService(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); localService.registerProvider(copilotAgent); const sessionResource = await localService.createSession({ provider: 'copilot' }); const sessionChangesetUri = URI.parse(buildSessionChangesetUri(sessionResource.toString())); @@ -11716,7 +11755,7 @@ suite('AgentService (node dispatcher)', () => { }; const sessionDataService = createSessionDataService(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); localService.registerProvider(copilotAgent); // Seed a session on the agent without calling @@ -11942,7 +11981,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); await localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } }); @@ -11960,7 +11999,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); await localService.createSession({ provider: 'copilot' }); @@ -11983,7 +12022,7 @@ suite('AgentService (node dispatcher)', () => { const localAgent = new MockAgent('codex'); localAgent.sessionMetadataOverrides = { workingDirectories: [workingDirectory], project: undefined }; disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); localService.setWorktreeIsolation(disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, gitService, @@ -12019,7 +12058,7 @@ suite('AgentService (node dispatcher)', () => { const model = { id: 'codex-model:openai:gpt-5.6-sol' }; localAgent.sessionMetadataOverrides = { model } as typeof localAgent.sessionMetadataOverrides; disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); const { session } = await createAgentSession(localAgent); await sessionDb.setChatDraft(URI.parse(buildDefaultChatUri(session)), { @@ -12046,7 +12085,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); // Create a session on the agent backend (no config) so listSessions can find it @@ -12076,7 +12115,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); const { session } = await createAgentSession(localAgent); @@ -12130,7 +12169,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); const { session } = await createAgentSession(localAgent); @@ -12181,7 +12220,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); const session = await localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } }); @@ -12208,7 +12247,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); const { session } = await createAgentSession(localAgent); @@ -12352,7 +12391,7 @@ suite('AgentService (node dispatcher)', () => { gitService.addWorktree = async () => { throw new Error('git worktree exited with code 128: git-lfs filter-process: git-lfs: command not found'); }; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const isolation = disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/failure' }, gitService, @@ -12419,7 +12458,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDataService = createSessionDataService(database); const gitService = createNoopGitService(); gitService.getRepositoryRoot = async () => undefined; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const isolation = disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/fallback' }, gitService, @@ -12498,7 +12537,7 @@ suite('AgentService (node dispatcher)', () => { }; }; gitService.computeSessionFileDiffs = async () => []; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const provisionalAgent = new ProvisionalMockAgent('provisional'); disposables.add(toDisposable(() => provisionalAgent.dispose())); localService.registerProvider(provisionalAgent); @@ -12677,7 +12716,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); const { session } = await createAgentSession(localAgent); @@ -12702,7 +12741,7 @@ suite('AgentService (node dispatcher)', () => { suite('Agent Merge durable session monitoring', () => { function createAgentMergeService(sessionDb: TestSessionDatabase, orchestratorDb: IAgentHostDatabase): AgentService { - const localService = disposables.add(new AgentService( + const localService = disposables.add(createTestAgentService( new NullLogService(), fileService, createSessionDataService(sessionDb), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, orchestratorDb, diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts new file mode 100644 index 00000000000000..633deb82c32a41 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../files/common/files.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { ILogService } from '../../../log/common/log.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +import { type IAgentCustomizationSettingsRegistration } from '../../common/agentCustomizationSettings.js'; +import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { AgentHostLaunchKind } from '../../common/agentHostTelemetry.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { IAgentHostDatabase } from '../../node/agentHostDatabase.js'; +import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from '../../node/agentHostFileMonitorService.js'; +import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; +import { AgentService } from '../../node/agentService.js'; +import { createAgentService } from '../../node/agentServiceComposition.js'; +import { ICopilotApiService } from '../../node/shared/copilotApiService.js'; + +export function createTestAgentService( + logService: ILogService, + fileService: IFileService, + sessionDataService: ISessionDataService, + productService: IProductService, + gitService: IAgentHostGitService, + rootConfigResource?: URI, + telemetryService: ITelemetryService = NullTelemetryService, + fileMonitorService?: IAgentHostFileMonitorService, + copilotApiService?: ICopilotApiService, + fetchFn: typeof globalThis.fetch = globalThis.fetch, + providerConfigurations: readonly IAgentCustomizationSettingsRegistration[] = [], + hostLaunchKind = AgentHostLaunchKind.Unknown, + storageResource?: URI, + orchestratorDatabase?: IAgentHostDatabase, +): AgentService { + const effectiveFileMonitorService = fileMonitorService ?? new AgentHostFileMonitorService(fileService, logService); + const proxyResolver: IAgentHostProxyResolver = { + _serviceBrand: undefined, + onDidRegisterConnection: Event.None, + onDidChangeConfiguration: Event.None, + register: () => Disposable.None, + bindConfigurationService: () => { }, + getConfigurationValue: () => undefined, + resolveProxy: async () => undefined, + fetch: fetchFn, + }; + const services = new ServiceCollection( + [ILogService, logService], + [IFileService, fileService], + [ISessionDataService, sessionDataService], + [IProductService, productService], + [IAgentHostGitService, gitService], + [ITelemetryService, telemetryService], + [IAgentHostFileMonitorService, effectiveFileMonitorService], + [IAgentHostProxyResolver, proxyResolver], + ); + const instantiationService = new InstantiationService(services, /*strict*/ true); + const options = { + rootConfigResource, + copilotApiService, + providerConfigurations, + hostLaunchKind, + storageResource, + orchestratorDatabase, + }; + const service = createAgentService( + options, + services, + instantiationService, + fetchFn, + logService, + productService, + fileMonitorService ? [instantiationService] : [effectiveFileMonitorService, instantiationService], + ); + return service; +} diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index cb65545d70cb7b..c9683ab91d07f3 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -40,7 +40,6 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentHostChangesetService, StaticChangesetKind } from '../../common/agentHostChangesetService.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; -import { AgentService } from '../../node/agentService.js'; import { AgentSideEffects, IAgentSideEffectsOptions } from '../../node/agentSideEffects.js'; import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import type { IAgentHostAskQuestionsToolInvokedEvent } from '../../node/agentHostTelemetryReporter.js'; @@ -54,6 +53,7 @@ import { applyMcpServerEnablement } from '../../node/shared/mcpCustomizationCont import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { MockAgent } from './mockAgent.js'; import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; +import { createTestAgentService } from './agentServiceTestUtils.js'; // ---- Tests ------------------------------------------------------------------ @@ -4997,7 +4997,7 @@ suite('AgentSideEffects', () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent(); disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); await localService.createSession({ provider: localAgent.id }); @@ -5016,7 +5016,7 @@ suite('AgentSideEffects', () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent(); disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); const session = await createAgentSession(localAgent); @@ -5043,7 +5043,7 @@ suite('AgentSideEffects', () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent(); disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(localAgent); const session = await createAgentSession(localAgent); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 86157080402e1b..c969f4f486a1cf 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -67,6 +67,7 @@ import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from '../.. import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; import { IAgentHostAuthenticationService, type IAgentHostAuthTokenChangeEvent } from '../../node/agentHostAuthenticationService.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; +import { createTestAgentService } from './agentServiceTestUtils.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { makeMcpServerCustomization } from '../../../agentPlugins/common/pluginParsers.js'; import { ClaudeAgent, fromSdkModelInfo } from '../../node/claude/claudeAgent.js'; @@ -84,7 +85,6 @@ import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { IClaudeProxyCreditsReport, IClaudeProxyHandle, IClaudeProxyService } from '../../node/claude/claudeProxyService.js'; import { resolvePromptToContentBlocks } from '../../node/claude/claudePromptResolver.js'; import { ICopilotApiService, type ICopilotApiServiceRequestOptions } from '../../node/shared/copilotApiService.js'; -import { AgentService } from '../../node/agentService.js'; import { createAgentChatContext } from '../../node/agentChatContext.js'; import { injectSideChatContext } from '../../node/agentPeerChats.js'; import { createNoopGitService, createNullSessionDataService, createSessionDataService, RecordingCheckpointService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; @@ -2155,7 +2155,7 @@ suite('ClaudeAgent', () => { test('AgentService surfaces the registered ClaudeAgent in the providers map', () => { const { agent } = createTestContext(disposables); const fileService = disposables.add(new FileService(new NullLogService())); - const service = disposables.add(new AgentService( + const service = disposables.add(createTestAgentService( new NullLogService(), fileService, createNullSessionDataService(), diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 2a62fb60a135ee..bb4f35c414038a 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -7,6 +7,7 @@ import { timeout } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { observableValue } from '../../../../base/common/observable.js'; import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; +import { join } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; @@ -602,33 +603,33 @@ export class ScriptedMockAgent implements IAgent { } async resolveChatConfig(params: IAgentResolveChatConfigParams): Promise<ResolveSessionConfigResult> { - const isolation = params.config?.isolation === 'folder' || params.config?.isolation === 'worktree' ? params.config.isolation : 'worktree'; - const branch = isolation === 'worktree' && typeof params.config?.branch === 'string' ? params.config.branch : 'main'; + const mode = params.config?.mockMode === 'direct' || params.config?.mockMode === 'managed' ? params.config.mockMode : 'managed'; + const branch = mode === 'managed' && typeof params.config?.mockBranch === 'string' ? params.config.mockBranch : 'main'; return { schema: { type: 'object', properties: { - isolation: { + mockMode: { type: 'string', - title: 'Isolation', - description: 'Where the mock agent should make changes', - enum: ['folder', 'worktree'], - enumLabels: ['Folder', 'Worktree'], - default: 'worktree', + title: 'Mock Mode', + description: 'How the mock agent should operate', + enum: ['direct', 'managed'], + enumLabels: ['Direct', 'Managed'], + default: 'managed', }, - branch: { + mockBranch: { type: 'string', - title: 'Branch', - description: 'Base branch to work from', + title: 'Mock Branch', + description: 'Mock branch to work from', enum: ['main'], enumLabels: ['main'], default: 'main', - enumDynamic: isolation === 'worktree', - readOnly: isolation === 'folder', + enumDynamic: mode === 'managed', + readOnly: mode === 'direct', }, }, }, - values: { isolation, branch }, + values: { mockMode: mode, mockBranch: branch }, }; } resolveSessionConfig(params: IAgentResolveChatConfigParams): Promise<ResolveSessionConfigResult> { @@ -640,7 +641,7 @@ export class ScriptedMockAgent implements IAgent { } async chatConfigCompletions(params: IAgentChatConfigCompletionsParams): Promise<SessionConfigCompletionsResult> { - if (params.property !== 'branch') { + if (params.property !== 'mockBranch') { return { items: [] }; } const query = params.query?.toLowerCase() ?? ''; @@ -709,7 +710,7 @@ export class ScriptedMockAgent implements IAgent { this._onDidChatProgress.fire(s); } await timeout(5); - this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: '/workspace/src/app.ts' })); + this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: join(process.cwd(), 'src/app.ts') })); // Auto-approved writes resolve immediately — complete the tool and turn await timeout(10); this._fireSequence([ @@ -728,7 +729,7 @@ export class ScriptedMockAgent implements IAgent { this._onDidChatProgress.fire(s); } await timeout(5); - this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: '/workspace/.env', confirmationTitle: 'Write .env' })); + this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: join(process.cwd(), '.env'), confirmationTitle: 'Write .env' })); })(); this._pendingPermissions.set('tc-write-env-1', (approved) => { if (approved) { @@ -816,7 +817,7 @@ export class ScriptedMockAgent implements IAgent { this._onDidChatProgress.fire(s); } await timeout(5); - this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-orphan', 'Read file', { permissionKind: 'read', permissionPath: '/workspace/file.ts' })); + this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-orphan', 'Read file', { permissionKind: 'read', permissionPath: join(process.cwd(), 'file.ts') })); })(); this._pendingPermissions.set('tc-orphan', (approved) => { if (approved) { diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts index b5a39d57ec7f44..f121caa419fb5e 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts @@ -52,29 +52,34 @@ suite('Protocol WebSocket - Session Config', function () { test('resolveSessionConfig returns schema and re-resolves dependent read-only state', async function () { this.timeout(10_000); - const workingDirectory = URI.file('/mock/workspace').toString(); + const workingDirectory = URI.file(process.cwd()).toString(); const initial = await client.call<ResolveSessionConfigResult>('resolveSessionConfig', { channel: ROOT_STATE_URI, provider: 'mock', workingDirectory, }); - assert.deepStrictEqual(initial.values, { isolation: 'worktree', branch: 'main' }); - assert.deepStrictEqual(Object.keys(initial.schema.properties), ['isolation', 'branch']); - assert.deepStrictEqual(initial.schema.properties.branch.enum, ['main']); - assert.strictEqual(initial.schema.properties.branch.enumDynamic, true); - assert.strictEqual(initial.schema.properties.branch.readOnly, false); + assert.deepStrictEqual({ + mockMode: initial.values.mockMode, + mockBranch: initial.values.mockBranch, + }, { mockMode: 'managed', mockBranch: 'main' }); + assert.deepStrictEqual(initial.schema.properties.mockBranch.enum, ['main']); + assert.strictEqual(initial.schema.properties.mockBranch.enumDynamic, true); + assert.strictEqual(initial.schema.properties.mockBranch.readOnly, false); - const folder = await client.call<ResolveSessionConfigResult>('resolveSessionConfig', { + const direct = await client.call<ResolveSessionConfigResult>('resolveSessionConfig', { channel: ROOT_STATE_URI, provider: 'mock', workingDirectory, - config: { isolation: 'folder', branch: 'feature/config' }, + config: { mockMode: 'direct', mockBranch: 'feature/config' }, }); - assert.deepStrictEqual(folder.values, { isolation: 'folder', branch: 'main' }); - assert.strictEqual(folder.schema.properties.branch.enumDynamic, false); - assert.strictEqual(folder.schema.properties.branch.readOnly, true); + assert.deepStrictEqual({ + mockMode: direct.values.mockMode, + mockBranch: direct.values.mockBranch, + }, { mockMode: 'direct', mockBranch: 'main' }); + assert.strictEqual(direct.schema.properties.mockBranch.enumDynamic, false); + assert.strictEqual(direct.schema.properties.mockBranch.readOnly, true); }); test('sessionConfigCompletions returns dynamic branch matches', async function () { @@ -83,9 +88,9 @@ suite('Protocol WebSocket - Session Config', function () { const result = await client.call<SessionConfigCompletionsResult>('sessionConfigCompletions', { channel: ROOT_STATE_URI, provider: 'mock', - workingDirectory: URI.file('/mock/workspace').toString(), - config: { isolation: 'worktree' }, - property: 'branch', + workingDirectory: URI.file(process.cwd()).toString(), + config: { mockMode: 'managed' }, + property: 'mockBranch', query: 'feat', }); @@ -97,11 +102,11 @@ suite('Protocol WebSocket - Session Config', function () { test('createSession stores config schema and values on session state', async function () { this.timeout(10_000); - const config = { isolation: 'worktree', branch: 'feature/config' }; + const config = { mockMode: 'managed', mockBranch: 'feature/config' }; await client.call('createSession', { channel: nextSessionUri(), provider: 'mock', - workingDirectories: [URI.file('/mock/workspace').toString()], + workingDirectories: [URI.file(process.cwd()).toString()], config, }); @@ -114,8 +119,11 @@ suite('Protocol WebSocket - Session Config', function () { const snapshot = await client.call<SubscribeResult>('subscribe', { channel: notification.summary.resource }); const state = snapshot.snapshot!.state as SessionState; - assert.deepStrictEqual(state.config?.values, config); - assert.deepStrictEqual(Object.keys(state.config?.schema.properties ?? {}), ['isolation', 'branch']); + assert.deepStrictEqual({ + mockMode: state.config?.values.mockMode, + mockBranch: state.config?.values.mockBranch, + }, config); + assert.deepStrictEqual(Object.keys(state.config?.schema.properties ?? {}).filter(key => key.startsWith('mock')), ['mockMode', 'mockBranch']); }); test('session/configChanged merges config values into session state', async function () { @@ -124,7 +132,7 @@ suite('Protocol WebSocket - Session Config', function () { await client.call('createSession', { channel: nextSessionUri(), provider: 'mock', - config: { isolation: 'folder', branch: 'main' }, + config: { mockMode: 'direct', mockBranch: 'main' }, }); const notif = await client.waitForNotification(n => @@ -140,7 +148,7 @@ suite('Protocol WebSocket - Session Config', function () { clientSeq: 1, action: { type: ActionType.SessionConfigChanged, - config: { branch: 'release' }, + config: { mockBranch: 'release' }, }, }); @@ -149,7 +157,10 @@ suite('Protocol WebSocket - Session Config', function () { const snapshot = await client.call<SubscribeResult>('subscribe', { channel: session }); const state = snapshot.snapshot!.state as SessionState; - assert.deepStrictEqual(state.config?.values, { isolation: 'folder', branch: 'release' }); + assert.deepStrictEqual({ + mockMode: state.config?.values.mockMode, + mockBranch: state.config?.values.mockBranch, + }, { mockMode: 'direct', mockBranch: 'release' }); }); }); @@ -172,7 +183,7 @@ suite('Protocol WebSocket - Session Config persistence across restarts', functio test('persisted config values are restored on subscribe after server restart', async function () { this.timeout(getAgentHostE2ETestTimeout(30_000, 180_000)); - const initialConfig = { isolation: 'worktree', branch: 'main' }; + const initialConfig = { mockMode: 'managed', mockBranch: 'main' }; const updatedBranch = 'release'; let sessionUri: string; @@ -186,7 +197,7 @@ suite('Protocol WebSocket - Session Config persistence across restarts', functio await client1.call('createSession', { channel: nextSessionUri(), provider: 'mock', - workingDirectories: [URI.file('/mock/workspace').toString()], + workingDirectories: [URI.file(process.cwd()).toString()], config: initialConfig, }); const addedNotif = await client1.waitForNotification(n => @@ -204,7 +215,7 @@ suite('Protocol WebSocket - Session Config persistence across restarts', functio clientSeq: 1, action: { type: ActionType.SessionConfigChanged, - config: { branch: updatedBranch }, + config: { mockBranch: updatedBranch }, }, }); const configChanged = await client1.waitForNotification(n => isActionNotification(n, ActionType.SessionConfigChanged)); @@ -235,10 +246,12 @@ suite('Protocol WebSocket - Session Config persistence across restarts', functio const state = snapshot.snapshot!.state as SessionState; assert.ok(state.config, 'restored session should have state.config populated'); - // Schema is re-resolved by the provider (worktree-mode mock returns - // dynamic branch enum), so just check that our persisted user + // Schema is re-resolved by the provider, so just check that our persisted user // selections survived the round trip. - assert.deepStrictEqual(state.config.values, { isolation: 'worktree', branch: updatedBranch }); + assert.deepStrictEqual({ + mockMode: state.config.values.mockMode, + mockBranch: state.config.values.mockBranch, + }, { mockMode: 'managed', mockBranch: updatedBranch }); client2.close(); } finally { diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts index 2f4474bceb9f41..b82f7f03968c8b 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; import type { IResponsePartAction } from '../../../common/state/sessionActions.js'; import { ResponsePartKind, type MarkdownResponsePart } from '../../../common/state/sessionState.js'; import { @@ -81,7 +82,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { test('auto-approves write to regular file (no pending confirmation)', async function () { this.timeout(10_000); - const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove', 'file:///workspace'); + const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove', URI.file(process.cwd()).toString()); client.clearReceived(); // Start a turn that triggers a write permission request for a regular .ts file @@ -107,7 +108,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { test('blocks write to .env file (requires manual confirmation)', async function () { this.timeout(10_000); - const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny', 'file:///workspace'); + const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny', URI.file(process.cwd()).toString()); client.clearReceived(); // Start a turn that tries to write .env (blocked by default patterns) @@ -195,7 +196,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { test('dispatches pending_confirmation that arrives without an active turn (does not hang)', async function () { this.timeout(10_000); - const sessionUri = await createAndSubscribeSession(client, 'test-orphan-confirmation', 'file:///workspace'); + const sessionUri = await createAndSubscribeSession(client, 'test-orphan-confirmation', URI.file(process.cwd()).toString()); client.clearReceived(); // The mock completes the turn, then simulates a hook-triggered From 0331adc16ef8d72e68df6a342cd5f276594fc028 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:25:07 -0700 Subject: [PATCH 08/15] reasoning ux: fix tools jumping + split reasoning summary headers (#331844) * reasoning ux: fix tools jumping + split reasoning summary headers * fix jump * fix reasoning ux --- .../chatThinkingContentPart.ts | 207 ++++++++- .../media/chatConfirmationWidget.css | 12 +- .../chatThinkingContentPart.test.ts | 407 +++++++++++++++++- 3 files changed, 619 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 1f015b075f8871..9bcc5099799422 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -174,6 +174,56 @@ function extractTitleFromThinkingContent(content: string): string | undefined { return headerMatch ? headerMatch[1] : undefined; } +/** A line that is entirely a bold span, e.g. `**Analyzing the request**`. */ +function isThinkingHeaderLine(line: string): boolean { + return /^\s*\*\*.+\*\*\s*$/.test(line); +} + +/** Strips the surrounding `**` when the whole text is a single bold span, so a standalone header renders as plain text. */ +function stripStandaloneBold(text: string): string { + const trimmed = text.trim(); + if (trimmed.startsWith('**') && trimmed.indexOf('**', 2) === trimmed.length - 2) { + return trimmed.slice(2, -2); + } + return text; +} + +/** + * Splits a reasoning-summary value into one markdown string per display row. + * Rows are delimited by bold header lines. When {@link dropLeadingHeader} is set + * and the value starts with a header, that header is dropped because it is + * surfaced as the collapsible title. Returns `undefined` unless the value has at + * least two header lines, so ordinary reasoning prose keeps single-block rendering. + */ +export function splitReasoningSummaryRows(text: string, dropLeadingHeader = true): string[] | undefined { + const sections: { isHeader: boolean; lines: string[] }[] = []; + for (const line of text.split('\n')) { + if (isThinkingHeaderLine(line)) { + sections.push({ isHeader: true, lines: [line] }); + } else if (sections.length === 0) { + sections.push({ isHeader: false, lines: [line] }); + } else { + sections[sections.length - 1].lines.push(line); + } + } + + if (sections.filter(section => section.isHeader).length < 2) { + return undefined; + } + + const dropFirst = dropLeadingHeader && sections[0].isHeader; + const rows: string[] = []; + sections.forEach((section, index) => { + const lines = index === 0 && dropFirst ? section.lines.slice(1) : section.lines; + const markdown = lines.join('\n').trim(); + if (markdown) { + rows.push(markdown); + } + }); + + return rows.length ? rows : undefined; +} + type ChatThinkingTitle = string | IMarkdownString; function getThinkingTitleValue(title: ChatThinkingTitle): string { @@ -333,6 +383,11 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private readonly workingTitle = localize('chat.thinking.header.working', 'Working'); private textContainer!: HTMLElement; private readonly _markdownResult = this._register(new MutableDisposable<IRenderedMarkdown>()); + private summaryRowItems: HTMLElement[] = []; + private summaryRowResults: (IRenderedMarkdown | undefined)[] = []; + private summaryRowTexts: string[] = []; + private droppedSummaryHeader: string | undefined; + private readonly retiredSummaryRowResults: IRenderedMarkdown[] = []; private wrapper!: HTMLElement; private fixedScrollingMode: boolean = false; private readonly thinkingDisplayMode: ThinkingDisplayMode; @@ -450,6 +505,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.extractedTitles.push(extractedTitle); } this.currentThinkingValue = initialText; + this.trackDroppedSummaryHeader(initialText); if (initialText.trim()) { this.appendedItemCount++; @@ -510,6 +566,15 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.ownedToolParts.clear(); })); + this._register(toDisposable(() => { + for (const result of this.summaryRowResults) { + result?.dispose(); + } + for (const result of this.retiredSummaryRowResults) { + result.dispose(); + } + })); + // override for codicon chevron in the collapsible part this._register(autorun(r => { const isExpanded = this.expanded.read(r); @@ -888,20 +953,40 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (this._store.isDisposed) { return; } + + // A later thinking part reassigns textContainer; retire stale row tracking + // so the predecessor's rendered rows stay frozen while this part renders. + if (this.summaryRowItems.length && this.summaryRowItems[0] !== this.textContainer) { + this.retireSummaryRows(); + } + const cleanedContent = content.trim(); if (!cleanedContent) { this._markdownResult.clear(); + this.clearSummaryRows(); if (this.textContainer) { clearNode(this.textContainer); } return; } - // If the entire content is bolded, strip the bold markers for rendering - let contentToRender = cleanedContent; - if (cleanedContent.startsWith('**') && cleanedContent.endsWith('**')) { - contentToRender = cleanedContent.slice(2, -2); + // Multi-header reasoning summaries render each header section as its own + // row so the dropdown reads as a list. Fixed-scrolling keeps its single + // auto-scrolling block. Sibling rows need an attached container so their + // insertion isn't a no-op, so a detached (lazy) container falls through to + // single-block rendering until it is materialized. A block drops its leading + // header only when that header is the tracked title owner, so a grouped block + // never drops a header that isn't surfaced as the title. + const dropLeadingHeader = this.droppedSummaryHeader !== undefined && extractTitleFromThinkingContent(cleanedContent) === this.droppedSummaryHeader; + const summaryRows = this.fixedScrollingMode ? undefined : splitReasoningSummaryRows(cleanedContent, dropLeadingHeader); + if (summaryRows && this.textContainer?.parentNode) { + this.renderSummaryRows(summaryRows); + return; } + this.clearSummaryRows(); + + // If the entire content is bolded, strip the bold markers for rendering + const contentToRender = stripStandaloneBold(cleanedContent); const target = reuseExisting ? this._markdownResult.value?.element : undefined; @@ -920,6 +1005,103 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } } + /** Renders one summary row, reusing the row's element while its text only grows. */ + private renderSummaryRow(container: HTMLElement, index: number, markdown: string): void { + const previous = this.summaryRowResults[index]; + const reuse = !!previous && markdown.startsWith(this.summaryRowTexts[index] ?? ''); + // A standalone header renders as plain text, not bold. + const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(stripStandaloneBold(markdown)), { + fillInIncompleteTokens: true, + asyncRenderCallback: this._asyncRenderCallback, + codeBlockRendererSync: ChatThinkingContentPart._codeBlockRendererSync, + }, reuse ? previous?.element : undefined); + if (!reuse) { + clearNode(container); + container.appendChild(createThinkingIcon(Codicon.circleFilled)); + container.appendChild(rendered.element); + } + previous?.dispose(); + this.summaryRowResults[index] = rendered; + this.summaryRowTexts[index] = markdown; + } + + private renderSummaryRows(rows: string[]): void { + // Rows own the DOM in this mode; release the single-block renderer. + this._markdownResult.clear(); + + for (let i = 0; i < rows.length; i++) { + let container = this.summaryRowItems[i]; + if (!container) { + container = i === 0 ? this.textContainer : $('.chat-thinking-item.markdown-content'); + this.summaryRowItems[i] = container; + this.summaryRowTexts[i] = ''; + if (i === 0) { + clearNode(container); + } else { + this.summaryRowItems[i - 1].after(container); + } + } + if (this.summaryRowTexts[i] !== rows[i]) { + this.renderSummaryRow(container, i, rows[i]); + } + } + + // Streaming only appends, but guard against a shrinking row set on re-render. + for (let i = this.summaryRowItems.length - 1; i >= rows.length; i--) { + this.summaryRowResults[i]?.dispose(); + if (this.summaryRowItems[i] !== this.textContainer) { + this.summaryRowItems[i].remove(); + } + } + this.summaryRowItems.length = rows.length; + this.summaryRowResults.length = rows.length; + this.summaryRowTexts.length = rows.length; + } + + /** Removes the extra summary rows and resets tracking, keeping the text container. */ + private clearSummaryRows(): void { + if (!this.summaryRowItems.length) { + return; + } + for (let i = 0; i < this.summaryRowItems.length; i++) { + this.summaryRowResults[i]?.dispose(); + if (i !== 0) { + this.summaryRowItems[i].remove(); + } + } + this.summaryRowItems = []; + this.summaryRowResults = []; + this.summaryRowTexts = []; + } + + /** Keeps a prior part's rendered rows in the DOM; defers disposal to teardown. */ + private retireSummaryRows(): void { + for (const result of this.summaryRowResults) { + if (result) { + this.retiredSummaryRowResults.push(result); + } + } + this.summaryRowItems = []; + this.summaryRowResults = []; + this.summaryRowTexts = []; + } + + /** + * Records the leading header the primary summary block drops, derived from content + * so it is available at finalize even when the rows never lazily rendered (the + * collapsed-through-completion flow). First-writer wins: the first grouped block + * that is a multi-header summary owns the title, and only that header is dropped. + */ + private trackDroppedSummaryHeader(value: string): void { + if (this.droppedSummaryHeader) { + return; + } + const trimmed = value.trim(); + if (!this.fixedScrollingMode && splitReasoningSummaryRows(trimmed, true)) { + this.droppedSummaryHeader = extractTitleFromThinkingContent(trimmed); + } + } + private setFinalizedTitle(title: string): void { if (!this._collapseButton) { return; @@ -1218,6 +1400,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const previousValue = this.currentThinkingValue; const reuseExisting = !!(this._markdownResult.value && next.startsWith(previousValue) && next.length > previousValue.length); this.currentThinkingValue = next; + this.trackDroppedSummaryHeader(next); this.renderMarkdown(next, reuseExisting); if (this.fixedScrollingMode && this.scrollableElement) { @@ -1315,6 +1498,15 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.updateDropdownClickability(); + // A leading summary header removed from the rows must remain the title, even when a restored generated title exists. + if (this.droppedSummaryHeader) { + this.currentTitle = this.droppedSummaryHeader; + this.content.generatedTitle = this.droppedSummaryHeader; + this.setGeneratedTitleOnAllParts(this.droppedSummaryHeader); + this.setFinalizedTitle(this.droppedSummaryHeader); + return; + } + if (this.content.generatedTitle) { this.currentTitle = this.content.generatedTitle; this.setGeneratedTitleOnAllParts(this.content.generatedTitle); @@ -2420,7 +2612,12 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): } this.appendedItemCount++; this.allThinkingParts.push(content); - this.recordReasoningContent(extractTextFromPart(content)); + const contentText = extractTextFromPart(content); + this.recordReasoningContent(contentText); + // First-writer wins: a later grouped block can be the first multi-header + // summary (when earlier blocks had <2 headers), so track it here too — the + // lazy/reload path never routes through updateThinking. + this.trackDroppedSummaryHeader(contentText); this.textContainer = $('.chat-thinking-item.markdown-content'); // Observe the new textContainer for child resizes in fixed scrolling mode if (this.childResizeObserver && this.fixedScrollingMode && !this.streamingCompleted) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css index ff2c204ebae8f1..9ac96db798c2de 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css @@ -524,6 +524,8 @@ } .chat-tool-invocation-part { + line-height: 1.5em; + .chat-confirmation-widget { border: none; font-size: var(--vscode-chat-font-size-body-s); @@ -541,12 +543,20 @@ padding: 2px 6px 2px 0px; &.monaco-button { - width: fit-content; outline: none; gap: 4px; } + &.monaco-text-button { + font-size: inherit; + font-family: inherit; + line-height: inherit; + padding-bottom: 0; + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; + } + .codicon { font-size: var(--vscode-codiconFontSize-compact); } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index ff2c20aad7a5e0..240db5ac402bbc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -17,7 +17,7 @@ import { IEditorService } from '../../../../../../services/editor/common/editorS import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ChatCollapsibleContentPart } from '../../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js'; -import { ChatThinkingContentPart, getToolInvocationIcon, maybePickFunWorkingMessage } from '../../../../browser/widget/chatContentParts/chatThinkingContentPart.js'; +import { ChatThinkingContentPart, getToolInvocationIcon, maybePickFunWorkingMessage, splitReasoningSummaryRows } from '../../../../browser/widget/chatContentParts/chatThinkingContentPart.js'; import { IChatExternalEdit, IChatMarkdownContent, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; import { IChatContentPartDiffData, IChatContentPartRenderContext, InlineTextModelCollection } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { IChatRendererContent, IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; @@ -645,6 +645,39 @@ suite('ChatThinkingContentPart', () => { assert.ok(thinkingItem, 'Should have thinking item'); }); + test('re-splits a summary into rows as later headers stream in', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const firstSummary = '**Refactoring session policy and cleanup**'; + const content = createThinkingPart(firstSummary); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + part.domNode.querySelector<HTMLElement>('.monaco-button')?.click(); + + part.updateThinking(createThinkingPart( + `${firstSummary}\n\n**Updating session token handling**`, + content.id + )); + + const rows = Array.from(part.domNode.querySelectorAll('.chat-thinking-item.markdown-content')); + assert.deepStrictEqual({ + rowTexts: rows.map(row => row.textContent?.trim()), + hasLiteralMarkers: part.domNode.textContent?.includes('**') ?? false, + }, { + rowTexts: ['Updating session token handling'], + hasLiteralMarkers: false, + }); + }); + test('should track multiple title extractions', () => { const content = createThinkingPart('**First title**'); const context = createMockRenderContext(false); @@ -696,6 +729,378 @@ suite('ChatThinkingContentPart', () => { }); }); + suite('Reasoning summary rows', () => { + setup(() => { + mockConfigurationService.setUserConfiguration('chat.agent.thinkingStyle', ThinkingDisplayMode.Collapsed); + }); + + function expandedSummaryRows(value: string): HTMLElement[] { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + createThinkingPart(value), + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + part.domNode.querySelector<HTMLElement>('.monaco-button')?.click(); + return Array.from(part.domNode.querySelectorAll<HTMLElement>('.chat-thinking-item.markdown-content')); + } + + test('splitReasoningSummaryRows parses headers, bodies, and prose', () => { + assert.deepStrictEqual({ + headersOnly: splitReasoningSummaryRows('**H1**\n\n**H2**\n\n**H3**'), + headerBodies: splitReasoningSummaryRows('**H1**\n\nbody1\n\n**H2**\n\nbody2'), + twoHeadersNoBody: splitReasoningSummaryRows('**H1**\n\n**H2**'), + leadingProse: splitReasoningSummaryRows('intro\n\n**H1**\n\n**H2**'), + keepLeadingHeader: splitReasoningSummaryRows('**H1**\n\n**H2**', false), + singleHeader: splitReasoningSummaryRows('**Only header**'), + prose: splitReasoningSummaryRows('Just thinking about the problem.'), + }, { + headersOnly: ['**H2**', '**H3**'], + headerBodies: ['body1', '**H2**\n\nbody2'], + twoHeadersNoBody: ['**H2**'], + leadingProse: ['intro', '**H1**', '**H2**'], + keepLeadingHeader: ['**H1**', '**H2**'], + singleHeader: undefined, + prose: undefined, + }); + }); + + test('keeps a later grouped block\'s leading header so no header is lost', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + createThinkingPart('**Reviewing the plan**\n\n**Weighing tradeoffs**'), + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + part.domNode.querySelector<HTMLElement>('.monaco-button')?.click(); + + part.setupThinkingContainer(createThinkingPart('**Editing files**\n\n**Verifying the change**', 'block-2')); + + const rowTexts = Array.from( + part.domNode.querySelectorAll<HTMLElement>('.chat-thinking-item.markdown-content'), + row => row.textContent?.trim() + ); + assert.deepStrictEqual({ + rowTexts, + hasLiteralMarkers: part.domNode.textContent?.includes('**') ?? false, + }, { + // First block drops its title header ("Reviewing the plan"); the grouped + // block keeps both of its headers so neither is lost. + rowTexts: ['Weighing tradeoffs', 'Editing files', 'Verifying the change'], + hasLiteralMarkers: false, + }); + }); + + test('renders each summary header as its own row and drops the leading header', () => { + const rows = expandedSummaryRows([ + '**Analyzing bold syntax with spaces**', + '**Examining special stripping logic**', + '**Explaining Markdown bold whitespace nuances**', + ].join('\n\n')); + + assert.deepStrictEqual({ + rowTexts: rows.map(row => row.textContent?.trim()), + hasLiteralMarkers: rows.some(row => row.textContent?.includes('**')), + anyRowBold: rows.some(row => !!row.querySelector('strong')), + }, { + rowTexts: ['Examining special stripping logic', 'Explaining Markdown bold whitespace nuances'], + hasLiteralMarkers: false, + anyRowBold: false, + }); + }); + + test('keeps a section body attached to its header row', () => { + const rows = expandedSummaryRows('**Reviewing the plan**\n\nWeigh the tradeoffs\n\n**Applying the change**\n\nEdit the file'); + + assert.deepStrictEqual(rows.map(row => ({ + strong: Array.from(row.querySelectorAll('strong'), element => element.textContent), + text: row.textContent?.replace(/\s+/g, ' ').trim(), + })), [ + { strong: [], text: 'Weigh the tradeoffs' }, + { strong: ['Applying the change'], text: 'Applying the changeEdit the file' }, + ]); + }); + + test('renders a single-header summary as one block', () => { + const rows = expandedSummaryRows('**Working on it**'); + + assert.deepStrictEqual({ + rowCount: rows.length, + text: rows[0]?.textContent?.trim(), + hasStrong: !!rows[0]?.querySelector('strong'), + }, { + rowCount: 1, + text: 'Working on it', + hasStrong: false, + }); + }); + + test('surfaces the dropped leading header as the finalized title', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const content = createThinkingPart('**Reviewing the plan**'); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + part.domNode.querySelector<HTMLElement>('.monaco-button')?.click(); + + part.updateThinking(createThinkingPart('**Reviewing the plan**\n\n**Weighing tradeoffs**\n\n**Applying the change**', content.id)); + part.finalizeTitleIfDefault(); + + const titleButton = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.deepStrictEqual({ + title: titleButton?.textContent?.trim(), + rows: Array.from(part.domNode.querySelectorAll<HTMLElement>('.chat-thinking-item.markdown-content'), row => row.textContent?.trim()), + }, { + title: 'Reviewing the plan', + rows: ['Weighing tradeoffs', 'Applying the change'], + }); + }); + + test('keeps the dropped header as the title over a restored content title', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const content = createThinkingPart('**Reviewing the plan**\n\n**Applying the change**'); + content.generatedTitle = 'Reviewed implementation details'; + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(true), + markdownRenderer, + true + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + part.finalizeTitleIfDefault(); + part.domNode.querySelector<HTMLElement>('.monaco-button')?.click(); + + assert.deepStrictEqual({ + title: part.domNode.querySelector('.chat-used-context-label .monaco-button')?.textContent?.trim(), + rows: Array.from(part.domNode.querySelectorAll<HTMLElement>('.chat-thinking-item.markdown-content'), row => row.textContent?.trim()), + generatedTitle: content.generatedTitle, + }, { + title: 'Reviewing the plan', + rows: ['Applying the change'], + generatedTitle: 'Reviewing the plan', + }); + }); + + test('keeps the dropped header as the title when a titled tool joins the group', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const content = createThinkingPart('**Analyzing the request**'); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + part.domNode.querySelector<HTMLElement>('.monaco-button')?.click(); + + part.updateThinking(createThinkingPart('**Analyzing the request**\n\n**Planning the edits**', content.id)); + + const toolInvocation = { + kind: 'toolInvocation', + toolId: 'edit', + toolCallId: 'call-1', + invocationMessage: 'Editing file.ts', + originMessage: undefined, + pastTenseMessage: undefined, + presentation: undefined, + source: ToolDataSource.Internal, + isAttachedToThinking: false, + generatedTitle: 'Edited implementation details', + state: observableValue('state', { + type: IChatToolInvocation.StateKind.Executing, + confirmed: { type: 0 }, + progress: observableValue('progress', { progress: 0 }), + parameters: {}, + confirmationMessages: undefined, + }), + toolSpecificDataKind: observableValue('tool', undefined), + toJSON: () => ({} as IChatToolInvocationSerialized), + } as unknown as IChatToolInvocation; + part.appendItem(() => ({ domNode: $('div.test-tool-item') }), toolInvocation.toolId, toolInvocation); + + part.finalizeTitleIfDefault(); + + const titleButton = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.deepStrictEqual({ + title: titleButton?.textContent?.trim(), + summaryRows: Array.from(part.domNode.querySelectorAll<HTMLElement>('.chat-thinking-item.markdown-content'), row => row.textContent?.trim()), + }, { + title: 'Analyzing the request', + summaryRows: ['Planning the edits'], + }); + }); + + test('keeps the dropped header as the title over a cached title', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const context = createMockRenderContext(true); + const thinkingId = 'restored-summary-part'; + const cacheKey = `${chatSessionResourceToId(context.element.sessionResource)}:${thinkingId}`; + instantiationService.get(IStorageService).store( + 'chat.thinkingTitleCache', + JSON.stringify({ [cacheKey]: { title: 'Reviewed implementation details', storedAt: Date.now() } }), + StorageScope.PROFILE, + StorageTarget.MACHINE + ); + const content = createThinkingPart('**Analyzing the request**\n\n**Verifying the result**', thinkingId); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + markdownRenderer, + true + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + part.finalizeTitleIfDefault(); + part.domNode.querySelector<HTMLElement>('.monaco-button')?.click(); + + assert.deepStrictEqual({ + title: part.domNode.querySelector('.chat-used-context-label .monaco-button')?.textContent?.trim(), + rows: Array.from(part.domNode.querySelectorAll<HTMLElement>('.chat-thinking-item.markdown-content'), row => row.textContent?.trim()), + generatedTitle: content.generatedTitle, + }, { + title: 'Analyzing the request', + rows: ['Verifying the result'], + generatedTitle: 'Analyzing the request', + }); + }); + + test('surfaces the dropped header as the title when collapsed through completion', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const content = createThinkingPart('**Analyzing the request**'); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + // Stays collapsed (rows never lazily render) through streaming and a tool call. + part.updateThinking(createThinkingPart('**Analyzing the request**\n\n**Planning the edits**', content.id)); + const toolInvocation = { + kind: 'toolInvocation', + toolId: 'edit', + toolCallId: 'call-1', + invocationMessage: 'Editing file.ts', + originMessage: undefined, + pastTenseMessage: undefined, + presentation: undefined, + source: ToolDataSource.Internal, + isAttachedToThinking: false, + generatedTitle: undefined, + state: observableValue('state', { + type: IChatToolInvocation.StateKind.Executing, + confirmed: { type: 0 }, + progress: observableValue('progress', { progress: 0 }), + parameters: {}, + confirmationMessages: undefined, + }), + toolSpecificDataKind: observableValue('tool', undefined), + toJSON: () => ({} as IChatToolInvocationSerialized), + } as unknown as IChatToolInvocation; + part.appendItem(() => ({ domNode: $('div.test-tool-item') }), toolInvocation.toolId, toolInvocation); + + part.finalizeTitleIfDefault(); + + const titleButton = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.strictEqual(titleButton?.textContent?.trim(), 'Analyzing the request'); + }); + + test('tracks the dropped header off a later grouped block when the first has one header', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + // The first block is a single-header block (renders as one block, drops nothing). + const content = createThinkingPart('**Reading the file**', 'block-0'); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + // A later grouped block is the first multi-header summary; collapsed through completion. + part.setupThinkingContainer(createThinkingPart('**Analyzing the request**\n\n**Planning the edits**', 'block-1')); + part.finalizeTitleIfDefault(); + + const titleButton = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.strictEqual(titleButton?.textContent?.trim(), 'Analyzing the request'); + }); + + test('does not drop a grouped block header that is not the tracked title', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + // Two multi-header summary blocks grouped, collapsed through completion. + const content = createThinkingPart('**Analyzing the request**\n\n**Reviewing constraints**', 'block-0'); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + part.setupThinkingContainer(createThinkingPart('**Editing files**\n\n**Verifying output**', 'block-1')); + part.finalizeTitleIfDefault(); + // Expand afterwards to materialize the lazy blocks. + part.domNode.querySelector<HTMLElement>('.monaco-button')?.click(); + + const titleButton = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + const rows = Array.from(part.domNode.querySelectorAll<HTMLElement>('.chat-thinking-item.markdown-content'), row => row.textContent?.trim()); + assert.deepStrictEqual({ + title: titleButton?.textContent?.trim(), + // The later block's leading header is not the tracked title, so it is kept as a row. + keepsLaterHeader: rows.includes('Editing files'), + }, { + title: 'Analyzing the request', + keepsLaterHeader: true, + }); + }); + }); + suite('Thinking group identity', () => { setup(() => { mockConfigurationService.setUserConfiguration('chat.agent.thinkingStyle', ThinkingDisplayMode.Collapsed); From c24699480975622ec9e5a7b933ea3031ff76acc7 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:22:07 +0200 Subject: [PATCH 09/15] sessions: keep ignored blocked sessions ignored (#331801) * sessions: keep ignored blocked sessions ignored Ignoring a blocked session (e.g. "Ignore CI Failure") only held until the user opened another session or the new-session view, after which the title bar surfaced the very same block again. Two independent causes: - `BlockedSessionsIndicatorModel` - the sole owner of the acknowledged occurrences - was created by `SessionsTitleBarWidget`, a command center action view item. The command center refreshes when `isNewChatSession` changes, disposing and re-creating its view items, which discarded every acknowledgement. Ownership moves to `SessionsTitleBarContribution`, which outlives those rebuilds and now also owns `SessionActionFeedback`. - `BlockedSessions` held its ref-counted GitHub pull request and CI model references on the reader's store, which is disposed *before* each recompute. Every session change therefore dropped the last reference, disposed the shared models and re-created empty ones, so the session briefly left the blocked set - and the cleanup autorun read that gap as "no longer blocked" and released the acknowledgement. Those references move to the reader's `delayedStore`, and a failing-CI acknowledgement (keyed by the failing commit) is now kept while a session is transiently absent, so only a new failing commit resurfaces it. Both models now trace their decisions (`[BlockedSessions]`, `[BlockedSessionsIndicator]`, `[SessionsTitleBar]`), covering the raw blocked set per recompute, every acknowledgement added/kept/released with its reason, blink queuing, the surfaced set, and command center widget churn, so a resurfacing session can be diagnosed from the logs alone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: correct SessionActionFeedback ownership documentation Its class JSDoc still said the title bar widget owns the instance, which contradicted the move of that ownership to SessionsTitleBarContribution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/blockedSessions.ts | 33 +++++- .../test/browser/blockedSessions.test.ts | 74 +++++++++++-- .../browser/blockedSessionsCIFixModel.ts | 7 +- .../browser/blockedSessionsIndicatorModel.ts | 102 ++++++++++++++---- .../sessions/browser/sessionActionFeedback.ts | 10 +- .../browser/sessionsTitleBarWidget.ts | 50 ++++----- .../blockedSessionsIndicatorModel.test.ts | 17 +++ .../sessionsTitleBarWidget.fixture.ts | 10 +- 8 files changed, 242 insertions(+), 61 deletions(-) diff --git a/src/vs/sessions/contrib/blockedSessions/browser/blockedSessions.ts b/src/vs/sessions/contrib/blockedSessions/browser/blockedSessions.ts index f5580d479e8ace..38346edd682089 100644 --- a/src/vs/sessions/contrib/blockedSessions/browser/blockedSessions.ts +++ b/src/vs/sessions/contrib/blockedSessions/browser/blockedSessions.ts @@ -6,11 +6,14 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { derivedOpts, IObservable, IReaderWithStore, observableFromEvent } from '../../../../base/common/observable.js'; import { equals } from '../../../../base/common/arrays.js'; +import { ILogService, LogLevel } from '../../../../platform/log/common/log.js'; import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { IGitHubService } from '../../github/browser/githubService.js'; import { GitHubCIOverallStatus, GitHubPullRequestState } from '../../github/common/types.js'; +const LOG_PREFIX = '[BlockedSessions]'; + /** * Why a session is surfaced as "blocked" (i.e. needs the user's attention). */ @@ -51,6 +54,7 @@ export class BlockedSessions extends Disposable { constructor( @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IGitHubService private readonly _gitHubService: IGitHubService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -67,15 +71,25 @@ export class BlockedSessions extends Disposable { owner: this, equalsFn: (a, b) => equals(a, b, (x, y) => x.session.sessionId === y.session.sessionId && x.reason === y.reason && x.occurrenceId === y.occurrenceId), }, reader => { + const sessions = this._allSessions.read(reader); const blocked: IBlockedSession[] = []; - for (const session of this._allSessions.read(reader)) { + for (const session of sessions) { // `derivedOpts` under-types the store-backed reader as `IReader`; it is an `IDerivedReader` at runtime. const blockedSession = this._getBlockedSession(reader as IReaderWithStore, session); if (blockedSession !== undefined) { blocked.push(blockedSession); } } - return blocked.sort((a, b) => b.session.updatedAt.read(reader).getTime() - a.session.updatedAt.read(reader).getTime()); + blocked.sort((a, b) => b.session.updatedAt.read(reader).getTime() - a.session.updatedAt.read(reader).getTime()); + // Traced on every recompute (not only when the result changes) so a + // session that briefly drops out — e.g. while its pull request or CI data + // is (re)loading — is visible in the log; such a gap is what makes an + // acknowledged block look like it came back on its own. The recompute runs + // on every session change, hence the explicit level check. + if (this._logService.getLevel() === LogLevel.Trace) { + this._logService.trace(`${LOG_PREFIX} computed blocked sessions (${blocked.length} of ${sessions.length}): ${describeBlockedSessions(blocked)}`); + } + return blocked; }); this.blockedSessions = derivedOpts({ @@ -108,7 +122,13 @@ export class BlockedSessions extends Disposable { return undefined; } - const prRef = reader.store.add(this._gitHubService.createPullRequestModelReference(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number)); + // `delayedStore` (released *after* the recompute) rather than `store` + // (released *before* it): these are ref-counted, shared models that are + // disposed once the last reference goes away. Releasing first would drop the + // last reference on every recompute, so each recompute would tear the loaded + // models down and re-create empty ones — reporting the session as unblocked + // until the data is fetched again. + const prRef = reader.delayedStore.add(this._gitHubService.createPullRequestModelReference(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number)); const livePR = prRef.object.pullRequest.read(reader); if (!livePR) { return undefined; @@ -118,7 +138,7 @@ export class BlockedSessions extends Disposable { return undefined; } - const ciRef = reader.store.add(this._gitHubService.createPullRequestCIModelReference(gitHubInfo.owner, gitHubInfo.repo, livePR.number, livePR.headSha)); + const ciRef = reader.delayedStore.add(this._gitHubService.createPullRequestCIModelReference(gitHubInfo.owner, gitHubInfo.repo, livePR.number, livePR.headSha)); if (ciRef.object.overallStatus.read(reader) === GitHubCIOverallStatus.Failure) { return { session, @@ -129,3 +149,8 @@ export class BlockedSessions extends Disposable { return undefined; } } + +/** Compact, log-friendly rendering of blocked sessions: `sessionId=occurrenceId`. */ +export function describeBlockedSessions(blocked: readonly IBlockedSession[]): string { + return `[${blocked.map(entry => `${entry.session.sessionId}=${entry.occurrenceId}`).join(', ')}]`; +} diff --git a/src/vs/sessions/contrib/blockedSessions/test/browser/blockedSessions.test.ts b/src/vs/sessions/contrib/blockedSessions/test/browser/blockedSessions.test.ts index 733824ba3451fd..3069ba67cac9c4 100644 --- a/src/vs/sessions/contrib/blockedSessions/test/browser/blockedSessions.test.ts +++ b/src/vs/sessions/contrib/blockedSessions/test/browser/blockedSessions.test.ts @@ -5,11 +5,12 @@ import assert from 'assert'; import { Emitter } from '../../../../../base/common/event.js'; -import { DisposableStore, ImmortalReference, type IReference } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, type IReference } from '../../../../../base/common/lifecycle.js'; import { autorun, ISettableObservable, observableValue, type IObservable } 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 { NullLogService } from '../../../../../platform/log/common/log.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { GitHubPullRequestCIModel } from '../../../github/browser/models/githubPullRequestCIModel.js'; import { GitHubPullRequestModel } from '../../../github/browser/models/githubPullRequestModel.js'; @@ -29,7 +30,7 @@ suite('BlockedSessions', () => { function createService(sessions: TestSession[], gitHubService: TestGitHubService): { service: BlockedSessions; management: TestSessionsManagementService } { const management = new TestSessionsManagementService(sessions as unknown as ISession[]); - const service = store.add(new BlockedSessions(management as unknown as ISessionsManagementService, gitHubService as unknown as IGitHubService)); + const service = store.add(new BlockedSessions(management as unknown as ISessionsManagementService, gitHubService as unknown as IGitHubService, new NullLogService())); // Keep the derived live so per-session model references are actually read. store.add(autorun(reader => { service.blockedSessions.read(reader); })); return { service, management }; @@ -147,6 +148,24 @@ suite('BlockedSessions', () => { const { service } = createService([session], gitHub); assert.deepStrictEqual(blockedReasons(service), [['both', BlockedSessionReason.FailingCI]]); }); + + test('keeps the pull request and CI models referenced across recomputes', () => { + // Sessions change constantly (opening a session, a status tick, ...) and each + // change recomputes the blocked set. Releasing the shared, ref-counted GitHub + // models while doing so would dispose them and report the session as + // unblocked until the data was fetched again - which silently discards the + // acknowledgement the user made for that very CI failure. + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 30, openPullRequest(30, 'sha30')); + gitHub.setCIStatus('owner', 'repo', 30, 'sha30', GitHubCIOverallStatus.Failure); + const session = new TestSession('ci', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 30 } }); + const { service, management } = createService([session], gitHub); + assert.deepStrictEqual(blockedIds(service), ['ci']); + + management.fireDidChangeSessions(); + + assert.deepStrictEqual({ blocked: blockedIds(service), released: gitHub.releasedModels }, { blocked: ['ci'], released: [] }); + }); }); function openPullRequest(number: number, headSha: string): IGitHubPullRequest { @@ -201,12 +220,19 @@ class TestSessionsManagementService extends mock<ISessionsManagementService>() { } override getSessions(): ISession[] { - return this._sessions; + // A fresh array per call, like the real service: every change event + // therefore invalidates the blocked-sessions computation. + return [...this._sessions]; } override getSession(resource: URI): ISession | undefined { return this._sessions.find(s => s.resource.toString() === resource.toString()); } + + /** Simulate any session change (a session opened, updated, created, ...). */ + fireDidChangeSessions(): void { + this._onDidChangeSessions.fire({} as ISessionsChangeEvent); + } } class TestGitHubService extends mock<IGitHubService>() { @@ -214,17 +240,29 @@ class TestGitHubService extends mock<IGitHubService>() { private readonly _prModels = new Map<string, TestPullRequestModel>(); private readonly _ciModels = new Map<string, TestCIModel>(); private readonly _reviewThreadModels = new Map<string, TestReviewThreadsModel>(); + private readonly _refCounts = new Map<string, number>(); + + /** + * Keys whose last reference was released. The real reference collections + * dispose the model at that point and re-create an empty one on the next + * acquire, losing everything that had been fetched - so consumers must keep + * these models referenced across recomputes. + */ + readonly releasedModels: string[] = []; override createPullRequestModelReference(owner: string, repo: string, prNumber: number): IReference<GitHubPullRequestModel> { - return new ImmortalReference(this._prModel(owner, repo, prNumber) as unknown as GitHubPullRequestModel); + const key = `${owner}/${repo}/${prNumber}`; + return this._acquire(key, this._prModel(owner, repo, prNumber)) as unknown as IReference<GitHubPullRequestModel>; } override createPullRequestCIModelReference(owner: string, repo: string, prNumber: number, headSha: string): IReference<GitHubPullRequestCIModel> { - return new ImmortalReference(this._ciModel(owner, repo, prNumber, headSha) as unknown as GitHubPullRequestCIModel); + const key = `${owner}/${repo}/${prNumber}/${headSha}`; + return this._acquire(key, this._ciModel(owner, repo, prNumber, headSha)) as unknown as IReference<GitHubPullRequestCIModel>; } override createPullRequestReviewThreadsModelReference(owner: string, repo: string, prNumber: number): IReference<GitHubPullRequestReviewThreadsModel> { - return new ImmortalReference(this._reviewThreadModel(owner, repo, prNumber) as unknown as GitHubPullRequestReviewThreadsModel); + const key = `${owner}/${repo}/${prNumber}/reviewThreads`; + return this._acquire(key, this._reviewThreadModel(owner, repo, prNumber)) as unknown as IReference<GitHubPullRequestReviewThreadsModel>; } setPullRequest(owner: string, repo: string, prNumber: number, pullRequest: IGitHubPullRequest): void { @@ -239,6 +277,27 @@ class TestGitHubService extends mock<IGitHubService>() { this._reviewThreadModel(owner, repo, prNumber).set(threads); } + private _acquire<T extends { reset(): void }>(key: string, object: T): IReference<T> { + this._refCounts.set(key, (this._refCounts.get(key) ?? 0) + 1); + let released = false; + return { + object, + dispose: () => { + if (released) { + return; + } + released = true; + const count = (this._refCounts.get(key) ?? 1) - 1; + this._refCounts.set(key, count); + if (count === 0) { + this.releasedModels.push(key); + // Stand in for the model being disposed and re-created empty. + object.reset(); + } + }, + }; + } + private _prModel(owner: string, repo: string, prNumber: number): TestPullRequestModel { const key = `${owner}/${repo}/${prNumber}`; let model = this._prModels.get(key); @@ -274,16 +333,19 @@ class TestPullRequestModel { private readonly _pullRequest = observableValue<IGitHubPullRequest | undefined>('test.pullRequest', undefined); readonly pullRequest: IObservable<IGitHubPullRequest | undefined> = this._pullRequest; set(pullRequest: IGitHubPullRequest): void { this._pullRequest.set(pullRequest, undefined); } + reset(): void { this._pullRequest.set(undefined, undefined); } } class TestCIModel { private readonly _overallStatus = observableValue<GitHubCIOverallStatus>('test.ciStatus', GitHubCIOverallStatus.Neutral); readonly overallStatus: IObservable<GitHubCIOverallStatus> = this._overallStatus; set(status: GitHubCIOverallStatus): void { this._overallStatus.set(status, undefined); } + reset(): void { this._overallStatus.set(GitHubCIOverallStatus.Neutral, undefined); } } class TestReviewThreadsModel { private readonly _reviewThreads = observableValue<readonly IGitHubPullRequestReviewThread[]>('test.reviewThreads', []); readonly reviewThreads: IObservable<readonly IGitHubPullRequestReviewThread[]> = this._reviewThreads; set(threads: readonly IGitHubPullRequestReviewThread[]): void { this._reviewThreads.set(threads, undefined); } + reset(): void { this._reviewThreads.set([], undefined); } } diff --git a/src/vs/sessions/contrib/sessions/browser/blockedSessionsCIFixModel.ts b/src/vs/sessions/contrib/sessions/browser/blockedSessionsCIFixModel.ts index 073d7b588a51ba..812670762dbdb9 100644 --- a/src/vs/sessions/contrib/sessions/browser/blockedSessionsCIFixModel.ts +++ b/src/vs/sessions/contrib/sessions/browser/blockedSessionsCIFixModel.ts @@ -58,13 +58,16 @@ export class BlockedSessionsCIFixModel extends Disposable implements ISessionCIF return undefined; } - const prRef = reader.store.add(this._gitHubService.createPullRequestModelReference(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number)); + // `delayedStore` (released *after* the recompute) keeps these ref-counted, + // shared models alive across a recompute; `store` would release the last + // reference first and force an empty model plus a refetch every time. + const prRef = reader.delayedStore.add(this._gitHubService.createPullRequestModelReference(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number)); const livePR = prRef.object.pullRequest.read(reader); if (!livePR) { return undefined; } - const ciRef = reader.store.add(this._gitHubService.createPullRequestCIModelReference(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number, livePR.headSha)); + const ciRef = reader.delayedStore.add(this._gitHubService.createPullRequestCIModelReference(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number, livePR.headSha)); const ciModel = ciRef.object; // Once a fix has been requested for the current head commit, hide the diff --git a/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts b/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts index bb4cab1eeb068c..5b9ea16fccd2bb 100644 --- a/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts +++ b/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts @@ -3,19 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { autorun, derived, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, agentSessionApprovalId } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISession } from '../../../services/sessions/common/session.js'; -import { BlockedSessionReason, BlockedSessions, IBlockedSession } from '../../blockedSessions/browser/blockedSessions.js'; +import { BlockedSessionReason, BlockedSessions, describeBlockedSessions, IBlockedSession } from '../../blockedSessions/browser/blockedSessions.js'; import { BlockedSessionsCIFixModel } from './blockedSessionsCIFixModel.js'; import { getFirstApprovalAcrossChats, IApprovedSession } from './views/sessionsList.js'; +const LOG_PREFIX = '[BlockedSessionsIndicator]'; + /** * The specific reason a homogeneous set of blocked sessions needs attention, * used to render a more helpful requires-input message. `undefined` (a mix of @@ -30,6 +33,17 @@ export const enum RequiresInputKind { FailingCI, } +/** + * A blocked occurrence the user has acknowledged, either by viewing the session + * or by explicitly ignoring it. + */ +interface IAcknowledgedOccurrence { + /** The acknowledged occurrence, as produced by `_getBlockOccurrenceId`. */ + readonly occurrenceId: string; + /** Why the session was blocked when it was acknowledged. */ + readonly reason: BlockedSessionReason; +} + /** * Model behind the sessions title bar's "N sessions require input" indicator. * @@ -41,7 +55,10 @@ export const enum RequiresInputKind { * block but never creates one. * * The DOM rendering of the indicator lives in the title bar widget; this class is - * DOM-free so it can be unit tested in isolation. + * DOM-free so it can be unit tested in isolation. It is owned by the title bar + * *contribution* rather than the widget, because the command center rebuilds its + * action view items (and so the widget) whenever its context keys change — e.g. + * when the new-session view opens — and acknowledgements must survive that. */ export class BlockedSessionsIndicatorModel extends Disposable { @@ -65,7 +82,7 @@ export class BlockedSessionsIndicatorModel extends Disposable { } /** Current blocked occurrences the user has already acknowledged, keyed by session id. */ - private readonly _ignoredBlockOccurrences = observableValue<ReadonlyMap<string, string>>('ignoredBlockOccurrences', new Map()); + private readonly _ignoredBlockOccurrences = observableValue<ReadonlyMap<string, IAcknowledgedOccurrence>>('ignoredBlockOccurrences', new Map()); /** * Blocked sessions that are not visible, ignored, being fixed, or already approved. @@ -106,6 +123,7 @@ export class BlockedSessionsIndicatorModel extends Disposable { @ISessionsService private readonly _sessionsService: ISessionsService, @IInstantiationService instantiationService: IInstantiationService, @IProductService productService: IProductService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -119,6 +137,12 @@ export class BlockedSessionsIndicatorModel extends Disposable { // The blocked-sessions feature is only enabled outside of stable builds. const enabled = productService.quality !== 'stable'; + // Acknowledgements live only in memory, so log both ends of this model's + // lifetime: a dispose here means every acknowledgement is discarded, which + // makes previously ignored sessions surface again. + this._logService.trace(`${LOG_PREFIX} created (enabled: ${enabled})`); + this._register(toDisposable(() => this._logService.trace(`${LOG_PREFIX} disposed, discarding ${this._ignoredBlockOccurrences.get().size} acknowledged occurrence(s)`))); + // A session that is currently visible on screen is not treated as blocked: // exclude visible sessions from the requires-input indicator and the dropdown. this.blockedSessions = derived(this, reader => { @@ -180,22 +204,46 @@ export class BlockedSessionsIndicatorModel extends Disposable { const next = new Map(ignoredOccurrences); let changed = false; - for (const [sessionId, ignoredOccurrence] of ignoredOccurrences) { + for (const [sessionId, acknowledged] of ignoredOccurrences) { const blockedSession = blockedById.get(sessionId); - if (!blockedSession || this._getBlockOccurrenceId(blockedSession, reader, ignoredOccurrence) !== ignoredOccurrence) { - next.delete(sessionId); - changed = true; + if (blockedSession) { + const occurrenceId = this._getBlockOccurrenceId(blockedSession, reader, acknowledged.occurrenceId); + if (occurrenceId !== acknowledged.occurrenceId) { + // A genuinely new block on the same session (a later approval, a + // newer failing commit): surface it again. + next.delete(sessionId); + changed = true; + this._logService.trace(`${LOG_PREFIX} releasing acknowledgement of ${sessionId}: new occurrence ${occurrenceId} replaces ${acknowledged.occurrenceId}`); + } + continue; + } + + // The session is no longer reported as blocked. A CI acknowledgement is + // keyed by the failing commit, so it is kept: the session can drop out + // transiently (its pull request / CI models reload, the session goes + // in progress) and must not resurface for the very failure the user + // already dismissed — a new commit yields a new occurrence anyway. An + // input-needed acknowledgement has no such identity, so it is released + // here to let the next input request surface. + if (acknowledged.reason === BlockedSessionReason.FailingCI) { + this._logService.trace(`${LOG_PREFIX} keeping acknowledgement of ${sessionId} (${acknowledged.occurrenceId}) while it is not reported as blocked`); + continue; } + next.delete(sessionId); + changed = true; + this._logService.trace(`${LOG_PREFIX} releasing acknowledgement of ${sessionId} (${acknowledged.occurrenceId}): no longer blocked`); } for (const blockedSession of blockedById.values()) { - if (!visibleSessionIds.has(blockedSession.session.sessionId)) { + const sessionId = blockedSession.session.sessionId; + if (!visibleSessionIds.has(sessionId)) { continue; } - const occurrenceId = this._getBlockOccurrenceId(blockedSession, reader, next.get(blockedSession.session.sessionId)); - if (next.get(blockedSession.session.sessionId) !== occurrenceId) { - next.set(blockedSession.session.sessionId, occurrenceId); + const occurrenceId = this._getBlockOccurrenceId(blockedSession, reader, next.get(sessionId)?.occurrenceId); + if (next.get(sessionId)?.occurrenceId !== occurrenceId) { + next.set(sessionId, { occurrenceId, reason: blockedSession.reason }); changed = true; + this._logService.trace(`${LOG_PREFIX} acknowledging ${sessionId} (${occurrenceId}): the session is visible`); } } @@ -214,7 +262,7 @@ export class BlockedSessionsIndicatorModel extends Disposable { const modelBlocked = this._blockedSessionsModel.blockedSessionsWithReasons.read(reader); const currentOccurrences = new Map(modelBlocked.map(blocked => [ blocked.session.sessionId, - this._getBlockOccurrenceId(blocked, reader, ignoredOccurrences.get(blocked.session.sessionId)), + this._getBlockOccurrenceId(blocked, reader, ignoredOccurrences.get(blocked.session.sessionId)?.occurrenceId), ] as const)); const previousOccurrences = this._lastBlockedOccurrences; this._lastBlockedOccurrences = currentOccurrences; @@ -241,12 +289,21 @@ export class BlockedSessionsIndicatorModel extends Disposable { if (previousOccurrences.get(sessionId) !== occurrenceId && !visibleSessionIds.has(sessionId)) { this._pendingBlinkOccurrences.set(sessionId, occurrenceId); queued = true; + this._logService.trace(`${LOG_PREFIX} queued attention blink for ${sessionId} (${occurrenceId})`); } } if (queued) { this._onDidRequestBlink.fire(); } })); + + // What the title bar actually surfaces, after visible / acknowledged / + // being-fixed sessions are filtered out. Traced so a resurfacing session can + // be correlated with the raw blocked set and the acknowledgements above. + this._register(autorun(reader => { + const surfaced = this.blockedSessions.read(reader); + this._logService.trace(`${LOG_PREFIX} surfacing ${surfaced.length} blocked session(s): ${describeBlockedSessions(surfaced)}`); + })); } /** @@ -263,7 +320,7 @@ export class BlockedSessionsIndicatorModel extends Disposable { const ignoredOccurrences = this._ignoredBlockOccurrences.get(); const surfacedOccurrences = new Map(this.blockedSessions.get().map(blocked => [ blocked.session.sessionId, - this._getBlockOccurrenceId(blocked, undefined, ignoredOccurrences.get(blocked.session.sessionId)), + this._getBlockOccurrenceId(blocked, undefined, ignoredOccurrences.get(blocked.session.sessionId)?.occurrenceId), ] as const)); let shouldBlink = false; for (const [sessionId, occurrenceId] of this._pendingBlinkOccurrences) { @@ -280,9 +337,10 @@ export class BlockedSessionsIndicatorModel extends Disposable { ignoreSession(session: ISession): void { const blocked = this._blockedSessionsModel.blockedSessionsWithReasons.get().find(entry => entry.session.sessionId === session.sessionId); if (!blocked) { + this._logService.trace(`${LOG_PREFIX} ignore requested for ${session.sessionId}, but it is not reported as blocked`); return; } - this._ignoreOccurrence(blocked, this._getBlockOccurrenceId(blocked, undefined, this._ignoredBlockOccurrences.get().get(session.sessionId))); + this._ignoreOccurrence(blocked, this._getBlockOccurrenceId(blocked, undefined, this._ignoredBlockOccurrences.get().get(session.sessionId)?.occurrenceId)); } /** Ignore every blocked occurrence currently surfaced by the indicator. */ @@ -293,7 +351,10 @@ export class BlockedSessionsIndicatorModel extends Disposable { } const next = new Map(this._ignoredBlockOccurrences.get()); for (const blocked of blockedSessions) { - next.set(blocked.session.sessionId, this._getBlockOccurrenceId(blocked, undefined, next.get(blocked.session.sessionId))); + const sessionId = blocked.session.sessionId; + const occurrenceId = this._getBlockOccurrenceId(blocked, undefined, next.get(sessionId)?.occurrenceId); + next.set(sessionId, { occurrenceId, reason: blocked.reason }); + this._logService.trace(`${LOG_PREFIX} ignoring ${sessionId} (${occurrenceId}): ignore all`); } this._ignoredBlockOccurrences.set(next, undefined); } @@ -338,13 +399,14 @@ export class BlockedSessionsIndicatorModel extends Disposable { private _ignoreOccurrence(blocked: IBlockedSession, occurrenceId: string): void { const next = new Map(this._ignoredBlockOccurrences.get()); - next.set(blocked.session.sessionId, occurrenceId); + next.set(blocked.session.sessionId, { occurrenceId, reason: blocked.reason }); this._ignoredBlockOccurrences.set(next, undefined); + this._logService.trace(`${LOG_PREFIX} ignoring ${blocked.session.sessionId} (${occurrenceId})`); } - private _isBlockIgnored(blocked: IBlockedSession, ignoredOccurrences: ReadonlyMap<string, string>, reader: IReader): boolean { - const ignoredOccurrence = ignoredOccurrences.get(blocked.session.sessionId); - return ignoredOccurrence !== undefined && this._getBlockOccurrenceId(blocked, reader, ignoredOccurrence) === ignoredOccurrence; + private _isBlockIgnored(blocked: IBlockedSession, ignoredOccurrences: ReadonlyMap<string, IAcknowledgedOccurrence>, reader: IReader): boolean { + const acknowledged = ignoredOccurrences.get(blocked.session.sessionId); + return acknowledged !== undefined && this._getBlockOccurrenceId(blocked, reader, acknowledged.occurrenceId) === acknowledged.occurrenceId; } private _getBlockOccurrenceId(blocked: IBlockedSession, reader: IReader | undefined, ignoredOccurrence?: string): string { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionActionFeedback.ts b/src/vs/sessions/contrib/sessions/browser/sessionActionFeedback.ts index ef00388d01e9c3..3ed7821593908a 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionActionFeedback.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionActionFeedback.ts @@ -13,9 +13,13 @@ import { IObservable, observableValue } from '../../../../base/common/observable * * When a session's pending action is approved, {@link approvedCount} briefly * reflects how many sessions were approved within a rolling window; each new - * approval increments the count and restarts the window. The sessions titlebar - * widget owns an instance and surfaces this as a transient "Approved N sessions" - * message. + * approval increments the count and restarts the window. The sessions title bar + * surfaces this as a transient "Approved N sessions" message. + * + * The instance is owned by `SessionsTitleBarContribution` rather than the title + * bar widget it is rendered by: the command center disposes and re-creates its + * action view items whenever its context keys change, which would otherwise cut + * the confirmation short. */ export class SessionActionFeedback extends Disposable { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 94dcfe9da6c035..2ac85df5a16e7c 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -24,6 +24,7 @@ import { URI } from '../../../../base/common/uri.js'; import { AnchorAlignment, AnchorPosition, IAnchor } from '../../../../base/common/layout.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { IContextViewService, IOpenContextView } from '../../../../platform/contextview/browser/contextView.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; @@ -32,11 +33,8 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se import { SHOW_SESSIONS_PICKER_COMMAND_ID } from './sessionsActions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { BlockedSessions } from '../../blockedSessions/browser/blockedSessions.js'; import { BlockedSessionsList, IBlockedSessionsHeaderActionContext, registerBlockedSessionsItemActions } from './blockedSessionsList.js'; -import { BlockedSessionsCIFixModel } from './blockedSessionsCIFixModel.js'; import { SessionActionFeedback } from './sessionActionFeedback.js'; -import { AgentSessionApprovalModel } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { BlockedSessionsIndicatorModel, RequiresInputKind } from './blockedSessionsIndicatorModel.js'; import { openSessionToTheSide } from './views/sessionsView.js'; import { getSessionWorkspaceDisplayInfo, ISessionWorkspaceDisplayInfo } from '../../../browser/sessionWorkspace.js'; @@ -143,6 +141,12 @@ const BLOCKED_DROPDOWN_MAX_WIDTH_RATIO = 0.9; * * Session actions (changes, terminal, etc.) are rendered via the * SessionTitleActions menu toolbar next to this widget. + * + * The widget is a command center action view item, so it is disposed and + * re-created whenever the command center rebuilds (for example when the + * new-session view opens and flips `isNewChatSession`). It therefore owns no + * durable state: the indicator model and the approval feedback are supplied by + * {@link SessionsTitleBarContribution}, which outlives those rebuilds. */ export class SessionsTitleBarWidget extends BaseActionViewItem { @@ -160,9 +164,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { private _workspaceInfo: ISessionWorkspaceDisplayInfo | undefined; private _isQuickChat = false; - /** Model behind the "N sessions require input" indicator (blocked-session set, blink, labels). */ - private readonly _blockedIndicator: BlockedSessionsIndicatorModel; - /** The currently open blocked-sessions dropdown, if any. */ private _openContextView: IOpenContextView | undefined; /** The blocked-sessions list rendered inside the open dropdown, if any. */ @@ -171,16 +172,13 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { /** Tracks whether the blocked-sessions dropdown is open (drives the Escape keybinding). */ private readonly _blockedSessionsVisibleContext: IContextKey<boolean>; - /** Drives the transient "Approved N sessions" confirmation. Owned by the widget. */ - private readonly _sessionActionFeedback: SessionActionFeedback; - constructor( action: SubmenuItemAction, options: IBaseActionViewItemOptions | undefined, - sessionActionFeedback: SessionActionFeedback | undefined, - approvalModel: AgentSessionApprovalModel | undefined, - blockedSessions: BlockedSessions | undefined, - ciFixModel: BlockedSessionsCIFixModel | undefined, + /** Drives the transient "Approved N sessions" confirmation. */ + private readonly _sessionActionFeedback: SessionActionFeedback, + /** Model behind the "N sessions require input" indicator (blocked-session set, blink, labels). */ + private readonly _blockedIndicator: BlockedSessionsIndicatorModel, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly sessionsService: ISessionsService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @@ -196,17 +194,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._blockedSessionsVisibleContext = SessionsBlockedSessionsVisibleContext.bindTo(contextKeyService); - // The widget owns the approval-feedback state; the optional parameter is a - // test seam so fixtures can supply a preset instance. - this._sessionActionFeedback = sessionActionFeedback ?? this._register(new SessionActionFeedback()); - - // The blocked-session indicator model owns the requires-input logic (the - // visible-filtered blocked set, the requires-input kind, optimistic approval - // dismissals, labels and blink detection). The optional `approvalModel`, - // `blockedSessions` and `ciFixModel` are test seams forwarded to it so - // fixtures can preset them. - this._blockedIndicator = this._register(this.instantiationService.createInstance(BlockedSessionsIndicatorModel, approvalModel, blockedSessions, ciFixModel)); - // Replay the attention blink when the model reports a genuinely new, not-yet- // visible block. Invalidate the cached render state so the identical pill is // rebuilt with the blink class (see `_render`). @@ -691,9 +678,19 @@ export class SessionsTitleBarContribution extends Disposable implements IWorkben constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @IInstantiationService instantiationService: IInstantiationService, + @ILogService logService: ILogService, ) { super(); + // The command center rebuilds its action view items whenever its menu or + // context keys change (e.g. opening the new-session view flips + // `isNewChatSession`), which disposes and re-creates the widget. State that + // must outlive those rebuilds — acknowledged blocked occurrences, the + // requires-input models and the transient approval confirmation — is owned + // here, not by the widget. + const sessionActionFeedback = this._register(new SessionActionFeedback()); + const blockedIndicator = this._register(instantiationService.createInstance(BlockedSessionsIndicatorModel, undefined /* approvalModel */, undefined /* blockedSessions */, undefined /* ciFixModel */)); + // Register the submenu item in the Agent Sessions command center this._register(MenuRegistry.appendMenuItem(Menus.CommandCenter, { submenu: Menus.TitleBarSessionTitle, @@ -724,7 +721,10 @@ export class SessionsTitleBarContribution extends Disposable implements IWorkben if (!(action instanceof SubmenuItemAction)) { return undefined; } - return instantiationService.createInstance(SessionsTitleBarWidget, action, options, undefined, undefined, undefined, undefined); + // Traced because each call means the command center threw the previous + // widget away; the state above deliberately survives it. + logService.trace('[SessionsTitleBar] creating the title bar widget'); + return instantiationService.createInstance(SessionsTitleBarWidget, action, options, sessionActionFeedback, blockedIndicator); }, undefined)); } } diff --git a/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts b/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts index dee4c9f3cd0258..3b279408a961dc 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts @@ -9,6 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { ISession } from '../../../../services/sessions/common/session.js'; @@ -42,6 +43,7 @@ suite('BlockedSessionsIndicatorModel', () => { sessionsService as unknown as ISessionsService, instantiationService, productService, + new NullLogService(), )); // Keep the derived live so it recomputes on visibility/dismissal changes. store.add(autorun(reader => { model.blockedSessions.read(reader); })); @@ -247,6 +249,21 @@ suite('BlockedSessionsIndicatorModel', () => { assert.deepStrictEqual(blockedIds(model), ['s1']); }); + test('keeps an ignored CI failure ignored when the session drops out of the blocked set', () => { + // The raw blocked set drops a session whenever its pull request / CI data is + // momentarily unavailable (e.g. while those models reload). Nothing changed + // about the failure, so the acknowledgement must survive that gap. + const { model, blockedModel } = createModel(); + const s1 = new TestSession('s1'); + blockedModel.setBlocked([failingCI(s1, 'sha1')]); + model.ignoreSession(s1 as unknown as ISession); + + blockedModel.setBlocked([]); + blockedModel.setBlocked([failingCI(s1, 'sha1')]); + + assert.deepStrictEqual(blockedIds(model), []); + }); + test('ignores all currently surfaced blocked sessions', () => { const { model, blockedModel } = createModel(); const input = new TestSession('input'); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts index d75e7f56d75d63..f68cd2d4f1d27e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -29,6 +29,8 @@ import { SessionActionFeedback } from '../../../../../sessions/contrib/sessions/ import { SessionsTitleBarWidget } from '../../../../../sessions/contrib/sessions/browser/sessionsTitleBarWidget.js'; // eslint-disable-next-line local/code-import-patterns import { BlockedSessionsCIFixModel } from '../../../../../sessions/contrib/sessions/browser/blockedSessionsCIFixModel.js'; +// eslint-disable-next-line local/code-import-patterns +import { BlockedSessionsIndicatorModel } from '../../../../../sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.js'; import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; @@ -181,7 +183,13 @@ function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): vo override readonly hiddenSessions: IObservable<ReadonlySet<string>> = constObservable<ReadonlySet<string>>(new Set()); }(); - const widget = disposableStore.add(instantiationService.createInstance(SessionsTitleBarWidget, action, undefined, sessionActionFeedback, approvalModel, blockedSessionsModel, ciFixModel)); + const widget = disposableStore.add(instantiationService.createInstance( + SessionsTitleBarWidget, + action, + undefined, + sessionActionFeedback, + disposableStore.add(instantiationService.createInstance(BlockedSessionsIndicatorModel, approvalModel, blockedSessionsModel, ciFixModel)), + )); widget.render(widgetHost); } From eb2df9f4296a8f2ee7017c59b25d1c906eea28b7 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:23:33 -0700 Subject: [PATCH 10/15] chat: add pet achievements and accessory rewards (#331883) * pet: add achievements and accessory rewards Add persistent cross-window pet achievements with six enabled rewards, a standalone collection modal, account badges, and semantic unlock triggers. Add the body-owned accessory rig and atlases, unlock star and New state, accessibility help, fixtures, and tests while retaining disabled rewards for later re-enablement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c8a4f1e-3bb0-4d6f-a3ed-249fbcd4ce15 * pet: address achievement review feedback Defer customization observation until the pet is enabled, detect newly installed MCP servers independently of enablement, and fully clear legacy fork state on reset. Rename the Crown persistence ID and use contrast-paired badge colors for the New affordance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c8a4f1e-3bb0-4d6f-a3ed-249fbcd4ce15 * pet: update model and skill rewards Reward changing the model picker selection with the Construction Hard Hat, and reward adding a custom skill with the Crown. Keep the instructions achievement and Sailor Hat disabled for future use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c8a4f1e-3bb0-4d6f-a3ed-249fbcd4ce15 * pet: fix component fixture asset loading Serve pet fixture media from the source tree used by both Vite and the CI rspack server, remove the intentionally empty screenshot variant, and approve the new blocking fixture snapshots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c8a4f1e-3bb0-4d6f-a3ed-249fbcd4ce15 * pet: accept component fixture screenshots Record the authoritative Linux CI hashes for the new blocking pet achievement and accessory fixtures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c8a4f1e-3bb0-4d6f-a3ed-249fbcd4ce15 * pet: restore fake timers within unlock test Avoid leaving the renderer test clock installed after the unlock-state interaction test so later notebook and notification suites can advance timers normally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c8a4f1e-3bb0-4d6f-a3ed-249fbcd4ce15 * remove unused achievements for now * pet: remove unrelated branch changes Restore server command, session artifact, and chat pill files to current main after they were accidentally included with the dormant achievement cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c8a4f1e-3bb0-4d6f-a3ed-249fbcd4ce15 --------- Copilot-Session: 5c8a4f1e-3bb0-4d6f-a3ed-249fbcd4ce15 --- .../skills/chat-pet-sprite-creation/SKILL.md | 42 + build/gulpfile.vscode.ts | 2 +- build/gulpfile.vscode.web.ts | 2 +- build/next/index.ts | 2 +- .../accessibility/browser/accessibleView.ts | 1 + .../browser/account.contribution.ts | 8 + .../browser/chatPetAchievementBadges.ts | 118 +++ .../media/chatPetAchievementBadges.css | 106 ++ .../test/browser/account.contribution.test.ts | 31 + .../contrib/chat/browser/chat.contribution.ts | 2 + .../chat/browser/chatPetAchievements.ts | 28 + .../contrib/chat/browser/modelPicker.ts | 6 + .../browser/sessionsChatAccessibilityHelp.ts | 1 + .../electron-browser/chat.contribution.ts | 12 + .../test/browser/chatPetAchievements.test.ts | 48 + .../mobile/mobileAgentHostModePicker.ts | 13 +- .../mobile/mobileChatInputConfigPicker.ts | 10 +- .../test/browser/sessionsRename.test.ts | 2 + .../browser/accessibilityConfiguration.ts | 7 +- .../features/browserEditorChatFeatures.ts | 19 +- .../browser/actions/chatAccessibilityHelp.ts | 2 +- .../chat/browser/chat.shared.contribution.ts | 4 + .../chat/browser/chatPetAchievementPreview.ts | 108 +++ .../chatPetAchievements.contribution.ts | 292 ++++++ .../chat/browser/chatPetAchievements.ts | 323 ++++++ .../chat/browser/chatPetAchievementsEditor.ts | 99 ++ .../browser/chatPetAchievementsEditorInput.ts | 54 ++ .../chat/browser/chatPetAchievementsWidget.ts | 295 ++++++ .../contrib/chat/browser/chatPetService.ts | 277 +++++- .../browser/media/chatPetAchievements.css | 235 +++++ .../widget/chatPetAccessoryRenderer.ts | 271 ++++++ .../browser/widget/chatPetAccessoryRig.ts | 232 +++++ .../chat/browser/widget/chatPetWidget.ts | 647 +++++++++--- .../contrib/chat/browser/widget/chatWidget.ts | 31 +- .../browser/widget/input/chatInputPart.ts | 7 + .../chat/browser/widget/media/chatPet.css | 62 +- .../chatPet/accessories/artist-beret.png | Bin 0 -> 904 bytes .../chatPet/accessories/baseball-cap.png | Bin 0 -> 935 bytes .../accessories/construction-hard-hat.png | Bin 0 -> 937 bytes .../media/chatPet/accessories/cowboy-hat.png | Bin 0 -> 946 bytes .../media/chatPet/accessories/crown.png | Bin 0 -> 970 bytes .../accessories/firefighter-helmet.png | Bin 0 -> 1068 bytes .../accessories/full-size-spinner-hat.png | Bin 0 -> 995 bytes .../accessories/grand-top-hat-monocle.png | Bin 0 -> 1143 bytes .../chatPet/accessories/leaning-party-hat.png | Bin 0 -> 973 bytes .../media/chatPet/accessories/sailor-hat.png | Bin 0 -> 836 bytes .../chatPet/accessories/viking-helmet.png | Bin 0 -> 1185 bytes .../chatPetAchievementsContribution.test.ts | 155 +++ .../browser/chatPetAchievementsEditor.test.ts | 120 +++ .../test/browser/widget/chatPetWidget.test.ts | 742 +++++++++++++- .../test/browser/widget/chatWidget.test.ts | 23 +- ...omizationManagementSectionRegistry.test.ts | 4 + .../chat/chatFixtureUtils.ts | 8 + .../chat/chatPetAccessoryRig.fixture.ts | 918 ++++++++++++++++++ .../chat/chatPetAchievementsEditor.fixture.ts | 102 ++ .../chat/chatPetFixtureUtils.ts | 111 +++ .../chatPetAchievementBadges.fixture.ts | 58 ++ .../blocks-ci-screenshots.md | 42 + 58 files changed, 5522 insertions(+), 160 deletions(-) create mode 100644 src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts create mode 100644 src/vs/sessions/contrib/accountMenu/browser/media/chatPetAchievementBadges.css create mode 100644 src/vs/sessions/contrib/chat/browser/chatPetAchievements.ts create mode 100644 src/vs/sessions/contrib/chat/test/browser/chatPetAchievements.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts create mode 100644 src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRig.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/artist-beret.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/baseball-cap.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/construction-hard-hat.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/cowboy-hat.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/crown.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/firefighter-helmet.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/full-size-spinner-hat.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/grand-top-hat-monocle.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/leaning-party-hat.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/sailor-hat.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/viking-helmet.png create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsContribution.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/chatPetAchievementBadges.fixture.ts diff --git a/.github/skills/chat-pet-sprite-creation/SKILL.md b/.github/skills/chat-pet-sprite-creation/SKILL.md index 346433c690c576..5397a7fe6a3581 100644 --- a/.github/skills/chat-pet-sprite-creation/SKILL.md +++ b/.github/skills/chat-pet-sprite-creation/SKILL.md @@ -171,6 +171,35 @@ Every frame uses the same rectangle. Never shift frame boundaries or add per-fra Choose a meaningful static pose that communicates the state without motion. Do not assume the first animation frame is automatically the best reduced-motion fallback. +## Layered accessories + +Wearable accessories use a body-owned attachment rig and one palette-independent atlas per appearance under `media/chatPet/accessories/`. Do not export copies for individual runtime states or frames. Body animation metadata owns attachment movement; an accessory atlas only contains the few canonical shapes needed when the body geometry changes. + +The default atlas is `256×192`, divided into `64×64` cells. A hat whose supplied silhouette genuinely needs the full 12-logical-pixel body width may declare `atlasCellSize: 96` and use a `384×288` atlas with `96×96` cells. Do not choose the wider tier merely to add detail; both tiers still use the same whole `8×8` logical pixels. + +```text +columns: upright | sleeping | impact | splat +row 0: head back layers +row 1: head front layers +row 2: right-eye front layers +``` + +An appearance may leave any cell transparent. For example, a plain hat leaves the eye row empty, while a combined Top Hat & Monocle appearance uses both head rows and the eye row. The airborne rig pose reuses the upright column. Add another canonical column only when an existing pose plus body-owned translation cannot preserve the intended silhouette. + +Attachment tracks live in `chatPetAccessoryRig.ts`. Each track uses compressed frame spans with a canonical pose and independent head/right-eye anchors. A slot is omitted while a body-authored expression, prop, or complete silhouette replacement owns the same geometry, such as dizzy eyes, sunglasses, or the rare icon transformation. When frames inside one sheet bake different facing directions, the track marks those frames so the head accessory mirrors with the body before the whole pet's outer facing transform is applied. Update the body-owned track only when body geometry changes. Adding an ordinary accessory must not require editing tracks or renderer code. + +Head-slot anchors include a one-logical-pixel wear offset so hats overlap the top of the head rather than resting on its silhouette. The love animation suppresses head accessories because its transformed head/antenna silhouette is the reaction. Treat slot visibility and fit as body-owned behavior, not per-hat exceptions. + +Appearances that tuck the antennae under a larger hat opt into `coversAntennae`. The compositor uses body-owned occlusion bounds before drawing the hat front, so the behavior follows animation tracks and mirroring without painting body-colored cover pixels into each atlas. + +Directional head accessories are authored for the canonical right-facing body and mirror with the complete pet canvas. A hat with directional structure should stay visually balanced over the head while expressing facing through a restrained one-logical-pixel cue in its brim, visor, lean, nose guard, or other asymmetric detail; do not shift the whole silhouette far off-center. Make asymmetric parts read correctly after mirroring. Identity-bound eye accessories may opt out of mirroring and use a direction-specific eye anchor so they remain on the same eye; the Top Hat & Monocle is the reference. Fixed-eye anchors must include the body origin shift for wide frames (`frameWidth - 96`). Review every appearance in both directions with the facing fixture. + +Static reduced-motion art may represent a later frame of its animated sheet. Keep `getChatPetReducedMotionRigFrame()` aligned with the representative body frame; do not assume rig frame zero merely because the static PNG has one image frame. + +Author every wearable part on the same `8×8` logical-pixel grid as the body. Brims, crowns, bands, rims, chains, highlights, and shadows must all be composed from whole aligned logical pixels; do not use diagonal polygons or source-pixel stair steps to imply curves. The small-effect exception does not apply to wearable accessories. Use the current Stable body sheets as geometry guides and validate that every alpha value is fully transparent or fully opaque. The runtime mirrors layers with the body; fixed-orientation body decorations are restored without erasing accessory pixels. + +When changing the accessory source at runtime, keep the current composite visible until the replacement image loads and passes exact dimension validation. Redraw the current frame without restarting the body animation. A failed or malformed accessory source falls back to the body alone and must not repeatedly retry. + ## Animation design Animate key poses, not noise. @@ -330,6 +359,19 @@ Keep the visuals silent: images, canvases, eyes, and effects use empty alt text - [ ] User-triggered behavior has localized screen-reader output. - [ ] Visual children are `aria-hidden`; the button owns semantics and tab order. - [ ] `ChatPetWidget` tests cover state names, exact timings, geometry, state priority, and reduced motion. +- [ ] Every runtime state maps to the intended body-owned attachment track. +- [ ] The atlas matches its declared compact (`256×192`) or wide (`384×288`) cell tier and the documented column/row contract. +- [ ] Every accessory color/alpha block is aligned to the `8×8` logical-pixel grid. +- [ ] Atlas artwork stays within the body canvas after applying the documented pivots and anchors. +- [ ] Head accessories overlap the head by the shared wear offset and are absent during love. +- [ ] Asymmetric artwork reads correctly in both canonical and mirrored facing directions. +- [ ] Adding the accessory requires no state-specific exports or renderer changes. +- [ ] Reduced-motion sources use the rig frame represented by their static body art. +- [ ] Body-authored eye expressions and props suppress incompatible eye-slot accessories. +- [ ] Switching accessories preserves the active body frame and animation timer. +- [ ] Accessory load failure leaves the body visible and does not retry continuously. + +Use the `chat/chatPetAccessoryRig/chatPetAccessoryRig/CriticalPoses` component fixture to review critical animated and reduced-motion poses, and `AllAccessoriesFacing` to compare every appearance in both directions. Run the focused unit tests using the repository's `unit-tests` skill. At minimum, run the `ChatPetWidget` test suite. diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 30267a48f07f23..7eb0a8288af0cf 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -113,7 +113,7 @@ const vscodeResourceIncludes = [ 'out-build/vs/workbench/contrib/welcomeOnboarding/browser/media/*.svg', // Chat Pet - 'out-build/vs/workbench/contrib/chat/browser/widget/media/chatPet/*.{gif,png}', + 'out-build/vs/workbench/contrib/chat/browser/widget/media/chatPet/**/*.{gif,png}', // Sessions 'out-build/vs/sessions/contrib/chat/browser/media/*.svg', diff --git a/build/gulpfile.vscode.web.ts b/build/gulpfile.vscode.web.ts index 18b85c9142adc2..7ff1d58c1c5418 100644 --- a/build/gulpfile.vscode.web.ts +++ b/build/gulpfile.vscode.web.ts @@ -75,7 +75,7 @@ export const vscodeWebResourceIncludes = [ 'out-build/vs/workbench/contrib/welcomeOnboarding/browser/media/*.svg', // Chat Pet - 'out-build/vs/workbench/contrib/chat/browser/widget/media/chatPet/*.{gif,png}', + 'out-build/vs/workbench/contrib/chat/browser/widget/media/chatPet/**/*.{gif,png}', // Extensions 'out-build/vs/workbench/contrib/extensions/browser/media/{theme-icon.png,language-icon.svg}', diff --git a/build/next/index.ts b/build/next/index.ts index 53f43b0279a413..defbaa1b9dd18a 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -249,7 +249,7 @@ const commonResourcePatterns = [ // SVGs referenced from CSS (needed for transpile/dev builds where CSS is copied as-is) 'vs/workbench/browser/media/code-icon.svg', 'vs/workbench/browser/parts/editor/media/letterpress*.svg', - 'vs/workbench/contrib/chat/browser/widget/media/chatPet/*.{gif,png}', + 'vs/workbench/contrib/chat/browser/widget/media/chatPet/**/*.{gif,png}', 'vs/sessions/contrib/chat/browser/media/*.svg', 'vs/sessions/contrib/welcome/browser/media/themePreviews/*.svg' ]; diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index 3cccaf4b64da5f..bf737ebf2e4dad 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -52,6 +52,7 @@ export const enum AccessibleViewProviderId { Survey = 'survey', Automations = 'automations', BrowserElementCommenting = 'browserElementCommenting', + ChatPetAchievements = 'chatPetAchievements', } export const enum AccessibleViewType { diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index e37dca5953c298..e176a324bb93f8 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -56,6 +56,8 @@ import { AgentHostCodexAgentEnabledSettingId } from '../../../../platform/agentH import { ChatAIDisabledSettingId } from '../../../../platform/chat/common/chatSettings.js'; import { CHAT_SETUP_ACTION_ID } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; 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'; // --- Account Menu Items --- // const AccountMenu = Menus.AccountMenu; @@ -622,6 +624,12 @@ 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.shouldShowCopilotDashboardHover()) { const footer = append(panel, $('section.sessions-account-titlebar-panel-footer', { 'aria-label': localize('sessionsAccountStatusSectionLabel', "Account status") diff --git a/src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts b/src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts new file mode 100644 index 00000000000000..28db1789439bc3 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatPetAchievementBadges.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +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, ChatPetAchievementId, IChatPetAchievement } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { ChatPetVariant, IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; + +export interface ISessionsChatPetAchievementBadge { + readonly achievement: IChatPetAchievement; + readonly unlocked: boolean; +} + +export function getSessionsChatPetAchievementBadges(enabled: boolean, unlockedAchievements: readonly ChatPetAchievementId[]): readonly ISessionsChatPetAchievementBadge[] | undefined { + if (!enabled) { + return undefined; + } + const unlocked = new Set(unlockedAchievements); + const badges = chatPetAchievements.map(achievement => ({ achievement, unlocked: unlocked.has(achievement.id) })); + return [ + ...badges.filter(badge => badge.unlocked), + ...badges.filter(badge => !badge.unlocked), + ]; +} + +export class SessionsChatPetAchievementBadges extends Disposable { + + readonly element: HTMLElement; + private readonly renderDisposables = this._register(new DisposableStore()); + private badgesList: HTMLElement | undefined; + + constructor( + parent: HTMLElement, + private readonly onOpenAchievements: () => void, + @IChatPetService private readonly chatPetService: IChatPetService, + @IThemeService private readonly themeService: IThemeService, + @IHoverService private readonly hoverService: IHoverService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.element = DOM.append(parent, DOM.$('section.sessions-chat-pet-achievement-badges')); + + const themeChanged = observableSignalFromEvent(this, this.themeService.onDidColorThemeChange); + this._register(autorun(reader => { + const badges = getSessionsChatPetAchievementBadges( + this.chatPetService.enabled.read(reader), + this.chatPetService.unlockedAchievements.read(reader), + ); + const variant = this.chatPetService.variant.read(reader); + themeChanged.read(reader); + this.render(badges, variant); + })); + } + + private render(badges: readonly ISessionsChatPetAchievementBadge[] | undefined, variant: ChatPetVariant): void { + const restoreListFocus = this.badgesList === DOM.getActiveElement(); + this.renderDisposables.clear(); + DOM.clearNode(this.element); + this.badgesList = undefined; + this.element.classList.toggle('hidden', badges === undefined); + if (!badges) { + return; + } + + this.element.setAttribute('aria-label', localize('sessionsChatPetBadgesSectionLabel', "Pet achievement badges")); + const header = DOM.append(this.element, DOM.$('.sessions-chat-pet-achievement-badges-header')); + DOM.append(header, DOM.$('h2.sessions-chat-pet-achievement-badges-title')).textContent = localize('sessionsChatPetBadgesTitle', "Badges"); + const unlockedCount = badges.filter(badge => badge.unlocked).length; + DOM.append(header, DOM.$('span.sessions-chat-pet-achievement-badges-count')).textContent = localize('sessionsChatPetBadgesCount', "{0} of {1} unlocked", unlockedCount, badges.length); + + const list = this.badgesList = DOM.append(this.element, DOM.$('ul.sessions-chat-pet-achievement-badges-list')); + list.tabIndex = 0; + list.setAttribute('aria-label', localize('sessionsChatPetBadgesListLabel', "Pet achievement badges, {0} of {1} unlocked", unlockedCount, badges.length)); + for (const badge of badges) { + const { achievement, unlocked } = badge; + const accessory = achievement.accessories[0]; + const item = DOM.append(list, DOM.$('li.sessions-chat-pet-achievement-badge')); + item.classList.toggle('locked', !unlocked); + item.setAttribute('aria-label', unlocked + ? localize('sessionsChatPetBadgeLabel', "{0} achievement badge: {1}", achievement.title, accessory.label) + : localize('sessionsChatPetBadgeLockedLabel', "Locked secret achievement badge")); + const canvas = DOM.append(item, 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'), item, unlocked ? achievement.title : localize('sessionsChatPetBadgeLocked', "Locked"))); + } + const actions = DOM.append(this.element, DOM.$('.sessions-chat-pet-achievement-badges-actions')); + const viewAchievements = this.renderDisposables.add(new Button(actions, { + ...defaultButtonStyles, + secondary: true, + ariaLabel: localize('sessionsChatPetViewAchievementsAriaLabel', "View Pet Achievements"), + })); + viewAchievements.label = localize('sessionsChatPetViewAchievements', "View Achievements"); + this.renderDisposables.add(viewAchievements.onDidClick(() => this.onOpenAchievements())); + + if (restoreListFocus) { + queueMicrotask(() => { + if (!this._store.isDisposed && list.isConnected) { + list.focus(); + } + }); + } + } +} diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/chatPetAchievementBadges.css b/src/vs/sessions/contrib/accountMenu/browser/media/chatPetAchievementBadges.css new file mode 100644 index 00000000000000..d52f255e96759a --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/media/chatPetAchievementBadges.css @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sessions-chat-pet-achievement-badges { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size60); + min-width: 0; + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size160); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-menu-separatorBackground); +} + +.sessions-chat-pet-achievement-badges.hidden { + display: none; +} + +.sessions-chat-pet-achievement-badges-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--vscode-spacing-size80); + min-width: 0; +} + +.sessions-chat-pet-achievement-badges-title { + min-width: 0; + margin: 0; + overflow: hidden; + color: var(--vscode-foreground); + font-size: var(--vscode-agents-fontSize-label1); + font-weight: var(--vscode-agents-fontWeight-semiBold); + text-overflow: ellipsis; + white-space: nowrap; +} + +.sessions-chat-pet-achievement-badges-count { + flex: 0 0 auto; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-agents-fontSize-label2); +} + +.sessions-account-titlebar-panel .sessions-chat-pet-achievement-badges-list { + display: flex; + flex-wrap: wrap; + align-self: stretch; + justify-content: flex-start; + gap: var(--vscode-spacing-size40); + margin: 0; + padding: 0; + border-radius: var(--vscode-cornerRadius-small); + list-style: none; +} + +.sessions-chat-pet-achievement-badges-list:focus { + outline: none; +} + +.sessions-chat-pet-achievement-badges-list:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + +.sessions-chat-pet-achievement-badge { + box-sizing: border-box; + display: flex; + flex: 0 0 28px; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + overflow: hidden; + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-small); + background: var(--vscode-editor-background); +} + +.sessions-chat-pet-achievement-badge-preview { + display: block; + width: 24px; + height: 24px; + image-rendering: pixelated; +} + +.sessions-chat-pet-achievement-badge.locked { + filter: grayscale(1); + opacity: 0.5; +} + +.sessions-chat-pet-achievement-badges-actions { + display: flex; + justify-content: flex-start; +} + +.sessions-chat-pet-achievement-badges-actions .monaco-button { + width: auto; + min-height: 24px; + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size60); + font-size: var(--vscode-agents-fontSize-label2); +} + +.hc-black .sessions-chat-pet-achievement-badge, +.hc-light .sessions-chat-pet-achievement-badge { + border-color: var(--vscode-contrastBorder); +} 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 06f2e1bf83fe64..13523e9fa17c11 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 @@ -9,8 +9,10 @@ import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/commo import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { CHAT_SETUP_ACTION_ID } from '../../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { ChatPetAchievementIds } from '../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; import { Menus } from '../../../../browser/menus.js'; import { shouldShowAccountPanelSummary } from '../../browser/account.contribution.js'; +import { getSessionsChatPetAchievementBadges } from '../../browser/chatPetAchievementBadges.js'; suite('Sessions - Account Menu', () => { @@ -53,4 +55,33 @@ suite('Sessions - Account Menu', () => { loading: false, }); }); + + test('shows unlocked badges first while the pet is enabled', () => { + assert.deepStrictEqual({ + disabled: getSessionsChatPetAchievementBadges(false, [ChatPetAchievementIds.FirstChatMessage]), + empty: getSessionsChatPetAchievementBadges(true, [])?.map(badge => ({ id: badge.achievement.id, unlocked: badge.unlocked })), + partial: getSessionsChatPetAchievementBadges(true, [ + ChatPetAchievementIds.IntegratedBrowserShared, + ChatPetAchievementIds.FirstChatMessage, + ])?.map(badge => ({ id: badge.achievement.id, unlocked: badge.unlocked })), + }, { + disabled: undefined, + empty: [ + { id: ChatPetAchievementIds.RequestRevision, unlocked: false }, + { id: ChatPetAchievementIds.FirstChatMessage, unlocked: false }, + { id: ChatPetAchievementIds.IntegratedBrowserShared, unlocked: false }, + { id: ChatPetAchievementIds.ModelSwitch, unlocked: false }, + { id: ChatPetAchievementIds.McpServerPresent, unlocked: false }, + { id: ChatPetAchievementIds.CustomSkillPresent, unlocked: false }, + ], + partial: [ + { id: ChatPetAchievementIds.FirstChatMessage, unlocked: true }, + { id: ChatPetAchievementIds.IntegratedBrowserShared, unlocked: true }, + { id: ChatPetAchievementIds.RequestRevision, unlocked: false }, + { id: ChatPetAchievementIds.ModelSwitch, unlocked: false }, + { id: ChatPetAchievementIds.McpServerPresent, unlocked: false }, + { id: ChatPetAchievementIds.CustomSkillPresent, unlocked: false }, + ], + }); + }); }); diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 745aeeb2b0e4bd..548b4b9b36ac50 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -48,6 +48,7 @@ import { Menus } from '../../../browser/menus.js'; import { ISessionsChatViewStateService, SessionsChatViewStateService } from './chatViewStateService.js'; import { SessionsChatResponseFileChangesService } from './sessionTurnChanges.js'; import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; +import { SessionsChatPetAchievementContribution } from './chatPetAchievements.js'; import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; @@ -122,6 +123,7 @@ registerWorkbenchContribution2(SessionsOpenerParticipantContribution.ID, Session registerWorkbenchContribution2(OpenSessionLinkOpenerContribution.ID, OpenSessionLinkOpenerContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(RegisterDefaultSessionTaskRunnersContribution.ID, RegisterDefaultSessionTaskRunnersContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(WorktreeCreatedTaskDispatcher.ID, WorktreeCreatedTaskDispatcher, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(SessionsChatPetAchievementContribution.ID, SessionsChatPetAchievementContribution, WorkbenchPhase.AfterRestored); // register services registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/chatPetAchievements.ts b/src/vs/sessions/contrib/chat/browser/chatPetAchievements.ts new file mode 100644 index 00000000000000..535be796c4b41c --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/chatPetAchievements.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { ChatPetAchievementIds, hasChatPetImageAttachment } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +export class SessionsChatPetAchievementContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.chatPetAchievements'; + + constructor( + @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @IChatPetService chatPetService: IChatPetService, + ) { + super(); + this._register(sessionsManagementService.onDidSendRequest(event => { + chatPetService.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + if (hasChatPetImageAttachment(event.options.attachedContext ?? [])) { + chatPetService.unlockAchievement(ChatPetAchievementIds.ImageRequest); + } + })); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/modelPicker.ts b/src/vs/sessions/contrib/chat/browser/modelPicker.ts index 01db41e6e55839..7999b27e48ed0c 100644 --- a/src/vs/sessions/contrib/chat/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/modelPicker.ts @@ -14,6 +14,8 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { IModelPickerDelegate, ModelPickerActionItem } from '../../../../workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.js'; +import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; import { IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { Menus } from '../../../browser/menus.js'; import { IsPhoneLayoutContext, SessionUsesCombinedConfigPickerContext } from '../../../common/contextkeys.js'; @@ -49,6 +51,7 @@ export class ModelPicker extends Disposable { @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, @ISessionContext private readonly _sessionContext: ISessionContext, @ISessionModelSelection private readonly _selectionModel: ISessionModelSelection, + @IChatPetService private readonly _chatPetService: IChatPetService, ) { super(); const currentModel = derived(this, reader => this._selectionModel.state.read(reader).currentModel); @@ -58,6 +61,9 @@ export class ModelPicker extends Disposable { setModel: model => { const previousModel = this._selectionModel.state.get().currentModel; if (this._selectionModel.selectModel(model.identifier)) { + if (didExplicitlySwitchChatPetModel(previousModel?.identifier, model.identifier)) { + this._chatPetService.unlockAchievement(ChatPetAchievementIds.ModelSwitch); + } reportNewChatPickerClosed(this._telemetryService, { id: 'NewChatModelPicker', optionIdBefore: previousModel?.identifier, diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index b7f588f023ed3e..2094641cd9e7fd 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -49,6 +49,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); 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.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.", '<keybinding:sessions.action.chat.toggleDictation>')); content.push(localize('sessionsChat.voiceMode', "Start or stop Voice Mode to interact with the agent using your microphone{0}.", '<keybinding:agentsVoice.startVoiceInChat>')); diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index 533bbeee63bfa0..b50922b7c95575 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -27,6 +27,8 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; import { ISessionsWindowOpenViewState, SessionsWindowOpenTelemetry, SessionsWindowSessionStartTelemetry } from '../../sessions/browser/sessionsWindowOpenTelemetry.js'; import { INewSessionComposerService, NewSessionWorkspacePreselectionSource } from '../browser/newSessionComposerService.js'; +import { ChatPetAchievementIds } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { @@ -224,8 +226,18 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon } } +class ChatPetAgentsWindowAchievementContribution implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.chatPetAgentsWindowAchievement'; + + constructor(@IChatPetService chatPetService: IChatPetService) { + chatPetService.unlockAchievement(ChatPetAchievementIds.AgentsWindowOpened); + } +} + registerWorkbenchContribution2(SelectAgentsFolderContribution.ID, SelectAgentsFolderContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(SessionsCopilotConfigSlashSubmitHandlerContribution.ID, SessionsCopilotConfigSlashSubmitHandlerContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatPetAgentsWindowAchievementContribution.ID, ChatPetAgentsWindowAchievementContribution, WorkbenchPhase.AfterRestored); // Renderer-side BYOK language-model handler that backs the node agent host's // OpenAI proxy, mirroring the registration in the workbench's diff --git a/src/vs/sessions/contrib/chat/test/browser/chatPetAchievements.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatPetAchievements.test.ts new file mode 100644 index 00000000000000..f642141253a2c3 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/chatPetAchievements.test.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ChatPetAchievementId, ChatPetAchievementIds } from '../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { ISendRequestSentEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { SessionsChatPetAchievementContribution } from '../../browser/chatPetAchievements.js'; + +suite('Sessions - Chat Pet Achievements', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('unlocks the first message and observes paused image sends', () => { + const onDidSendRequest = disposables.add(new Emitter<ISendRequestSentEvent>()); + const attemptedUnlocks: ChatPetAchievementId[] = []; + const sessionsManagementService = new class extends mock<ISessionsManagementService>() { + override readonly onDidSendRequest = onDidSendRequest.event; + }(); + const chatPetService = new class extends mock<IChatPetService>() { + override unlockAchievement(id: ChatPetAchievementId): boolean { + attemptedUnlocks.push(id); + return false; + } + }(); + disposables.add(new SessionsChatPetAchievementContribution(sessionsManagementService, chatPetService)); + + onDidSendRequest.fire({ + session: undefined!, + chat: undefined!, + isNewSession: true, + isNewChat: true, + options: { + query: 'hello', + attachedContext: [{ kind: 'image', id: 'image', name: 'image', value: '' }], + }, + }); + + assert.deepStrictEqual(attemptedUnlocks, [ + ChatPetAchievementIds.FirstChatMessage, + ChatPetAchievementIds.ImageRequest, + ]); + }); +}); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts index 9216eafe8203c1..cbe1156467b42e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts @@ -8,6 +8,8 @@ import { IHoverService } from '../../../../../../platform/hover/browser/hover.js import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatPhoneInputPresenter } from '../../../../../../workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.js'; +import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../../../workbench/contrib/chat/browser/chatPetService.js'; import { IObservable } from '../../../../../../base/common/observable.js'; import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; @@ -33,6 +35,7 @@ export class MobileAgentHostModePicker extends AgentHostModePicker { @IHoverService hoverService: IHoverService, @IChatPhoneInputPresenter private readonly _phonePresenter: IChatPhoneInputPresenter, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IChatPetService private readonly _chatPetService: IChatPetService, ) { super(session, actionWidgetService, sessionsProvidersService, telemetryService, hoverService); } @@ -52,9 +55,13 @@ export class MobileAgentHostModePicker extends AgentHostModePicker { getSessionContext: () => createChatPhoneInputSessionContext(this._session.get()), selectModel: modelIdentifier => { const chatResource = this._session.get()?.activeChat.get().resource; - return chatResource - ? this._chatWidgetService.getWidgetBySessionResource(chatResource)?.inputPart.switchModelByIdentifier(modelIdentifier, true, true) ?? false - : false; + const inputPart = chatResource ? this._chatWidgetService.getWidgetBySessionResource(chatResource)?.inputPart : undefined; + const previousModelIdentifier = inputPart?.selectedLanguageModel.get()?.identifier; + const selected = inputPart?.switchModelByIdentifier(modelIdentifier, true, true) ?? false; + if (selected && didExplicitlySwitchChatPetModel(previousModelIdentifier, modelIdentifier)) { + this._chatPetService.unlockAchievement(ChatPetAchievementIds.ModelSwitch); + } + return selected; }, }) .finally(() => { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts index 3d9c305dd71dae..2c22db7792db02 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts @@ -21,6 +21,8 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { type ILanguageModelChatMetadataAndIdentifier } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { IChatPhoneInputPresenter } from '../../../../../../workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.js'; import { getModelProviderIcon } from '../../../../../../workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.js'; +import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../../../workbench/contrib/chat/browser/chatPetService.js'; import { Menus } from '../../../../../browser/menus.js'; import { SessionUsesCombinedConfigPickerContext, IsPhoneLayoutContext } from '../../../../../common/contextkeys.js'; import { type IAgentHostSessionsProvider, isAgentHostProvider, isAgentHostProviderId } from '../../../../../common/agentHostSessionsProvider.js'; @@ -81,6 +83,7 @@ class MobileChatInputConfigPicker extends Disposable { @INewChatModelPickerService private readonly _newChatModelPickerService: INewChatModelPickerService, @ISessionModelSelection private readonly _selectionModel: ISessionModelSelection, @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IChatPetService private readonly _chatPetService: IChatPetService, ) { super(); this._register(this._newChatModelPickerService.registerModelPicker({ @@ -257,7 +260,12 @@ class MobileChatInputConfigPicker extends Disposable { } private _switchToModel(modelIdentifier: string): boolean { - return this._selectionModel.selectModel(modelIdentifier); + const previousModelIdentifier = this._selectionModel.state.get().currentModel?.identifier; + const selected = this._selectionModel.selectModel(modelIdentifier); + if (selected && didExplicitlySwitchChatPetModel(previousModelIdentifier, modelIdentifier)) { + this._chatPetService.unlockAchievement(ChatPetAchievementIds.ModelSwitch); + } + return selected; } private async _showSheet(): Promise<void> { 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 9591727dbfbf53..e60e2da74f97cc 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts @@ -245,11 +245,13 @@ suite('Sessions rename', () => { assert.deepStrictEqual({ hasDoubleClick: content.includes('double-click its title'), hasContextMenu: content.includes('open its context menu'), + hasPetAchievements: content.includes('View Achievements'), activeElement: mainWindow.document.activeElement, fallbackFocusCount: fallbackFocusCount(), }, { hasDoubleClick: true, hasContextMenu: true, + hasPetAchievements: true, activeElement: origin, fallbackFocusCount: 0, }); diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index fcb66d1eb7217e..4436932b9b2756 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -73,7 +73,8 @@ export const enum AccessibilityVerbositySettingId { ChatQuestionCarousel = 'accessibility.verbosity.chatQuestionCarousel', Survey = 'accessibility.verbosity.survey', Automations = 'accessibility.verbosity.automations', - BrowserElementCommenting = 'accessibility.verbosity.browserElementCommenting' + BrowserElementCommenting = 'accessibility.verbosity.browserElementCommenting', + ChatPetAchievements = 'accessibility.verbosity.chatPetAchievements' } const baseVerbosityProperty: IConfigurationPropertySchema = { @@ -235,6 +236,10 @@ const configuration: IConfigurationNode = { description: localize('verbosity.browserElementCommenting', 'Provide information about how to access element commenting accessibility help in the Integrated Browser.'), ...baseVerbosityProperty }, + [AccessibilityVerbositySettingId.ChatPetAchievements]: { + description: localize('verbosity.chatPetAchievements', 'Provide information about how to access chat pet achievements accessibility help when the Achievements modal is focused.'), + ...baseVerbosityProperty + }, 'accessibility.signalOptions.volume': { 'description': localize('accessibility.signalOptions.volume', "The volume of the sounds in percent (0-100)."), 'type': 'number', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index 765e116015505e..d1f8e580ad06bf 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -46,6 +46,8 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType, IAccessibleViewService } from '../../../../../platform/accessibility/browser/accessibleView.js'; import { AccessibleViewRegistry, IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; +import { ChatPetAchievementIds, shouldUnlockChatPetIntegratedBrowserShare } from '../../../chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../chat/browser/chatPetService.js'; // Register tools import '../tools/browserTools.contribution.js'; @@ -180,6 +182,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, @IAccessibleViewService private readonly accessibleViewService: IAccessibleViewService, + @IChatPetService private readonly chatPetService: IChatPetService, ) { super(editor); this._elementSelectionModeContext = CONTEXT_BROWSER_ELEMENT_SELECTION_MODE.bindTo(contextKeyService); @@ -204,7 +207,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { this._shareButton.label = '$(share-window)'; this._register(this._shareButton.onDidClick(() => { - this._toggleShareWithAgent(); + void this._toggleShareWithAgent(); })); // Auto-disable element selection when the user sends a chat request. @@ -303,12 +306,22 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { // -- Sharing ------------------------------------------------------- - private _toggleShareWithAgent(): void { + private async _toggleShareWithAgent(): Promise<void> { const model = this.editor.model; if (!model) { return; } - model.setSharedWithAgent(model.sharingState !== BrowserViewSharingState.Shared); + const shared = model.sharingState !== BrowserViewSharingState.Shared; + let succeeded: boolean; + try { + succeeded = await model.setSharedWithAgent(shared); + } catch (error) { + this.logService.error('BrowserEditor.toggleShareWithAgent: Failed to update sharing state', error); + return; + } + if (shouldUnlockChatPetIntegratedBrowserShare(shared, succeeded)) { + this.chatPetService.unlockAchievement(ChatPetAchievementIds.IntegratedBrowserShared); + } } private _updateSharingState(isInitialState: boolean): void { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 56a0207afbb114..0d829b198e6d2b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -82,7 +82,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.agentHostApprovalsPicker', 'When an agent session exposes approval presets, use Tab to reach the Approvals picker and choose how it handles workspace access, commands, and the internet.')); } content.push(localize('chat.requestHistory', 'In the input box, use up and down arrows to navigate your request history. Edit input and use enter or the submit button to run a new request.')); - 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. 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; rapidly alternate the unmodified arrows to make it dizzy. Press Enter or Space while it is resting to interact with it. Open its context menu{0} (for example Shift+F10), use the up and down arrow keys to choose Go on the Run, Come Back, Grow, Shrink, Stable Colors, or Insiders Colors, and press Enter to activate the choice. Grow and Shrink change its size in twenty-percent steps. The pet position and selected size are shared across chats and windows and remembered after you restart.', '<keybinding:editor.action.showContextMenu>')); + 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. 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; 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, Stable Colors, or Insiders Colors, and press Enter to activate the choice. Grow and Shrink change its size in twenty-percent steps. The pet position and selected size are shared across chats and windows and remembered after you restart.', '<keybinding:editor.action.showContextMenu>')); 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.', '<keybinding:editor.action.showHover>')); 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 289bb383c9bc3f..f3d4cc55a4aac3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -196,6 +196,7 @@ import { ChatVariablesService } from './attachments/chatVariables.js'; import { ChatImageCarouselService, IChatImageCarouselService } from './chatImageCarouselService.js'; import { ChatOutputRendererService, IChatOutputRendererService } from './chatOutputItemRenderer.js'; import { ChatCompatibilityNotifier, ChatExtensionPointHandler } from './chatParticipant.contribution.js'; +import { ChatPetAchievementsAccessibilityHelp, ChatPetContextContribution, ChatPetCustomizationAchievementContribution } from './chatPetAchievements.contribution.js'; import { ChatPetService, IChatPetService } from './chatPetService.js'; import { ChatPromoNotificationContribution } from './chatPromoNotification.js'; import { ChatQuotaNotificationContribution } from './chatQuotaNotification.js'; @@ -3003,6 +3004,7 @@ AccessibleViewRegistry.register(new QuickChatAccessibilityHelp()); AccessibleViewRegistry.register(new EditsChatAccessibilityHelp()); AccessibleViewRegistry.register(new AgentChatAccessibilityHelp()); AccessibleViewRegistry.register(new ChatFindAccessibilityHelp()); +AccessibleViewRegistry.register(new ChatPetAchievementsAccessibilityHelp()); registerEditorFeature(ChatInputBoxContentProvider); Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); @@ -3065,6 +3067,8 @@ registerWorkbenchContribution2(AgentPluginCommandsContribution.ID, AgentPluginCo registerWorkbenchContribution2(PluginAutoUpdate.ID, PluginAutoUpdate, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatReferenceAttachmentWidgetContribution.ID, ChatReferenceAttachmentWidgetContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(TranscriptContextAttachmentWidgetContribution.ID, TranscriptContextAttachmentWidgetContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatPetContextContribution.ID, ChatPetContextContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatPetCustomizationAchievementContribution.ID, ChatPetCustomizationAchievementContribution, WorkbenchPhase.AfterRestored); registerChatActions(); registerChatAccessibilityActions(); diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts new file mode 100644 index 00000000000000..2a18f19c8307fa --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +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 { IChatPetAccessory } from './chatPetAchievements.js'; +import { drawChatPetAccessory, drawChatPetComposite, getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from './widget/chatPetAccessoryRenderer.js'; + +export const CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE = 96; + +export function renderChatPetAchievementPreview( + canvas: HTMLCanvasElement, + accessory: IChatPetAccessory | undefined, + unlocked: boolean, + variant: ChatPetVariant, + themeService: IThemeService, + logService: ILogService, +): IDisposable { + const store = new DisposableStore(); + const targetWindow = DOM.getWindow(canvas); + const bodyImage = targetWindow.document.createElement('img'); + const accessoryImage = accessory ? targetWindow.document.createElement('img') : 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; + let accessoryLoaded = accessory === undefined; + const draw = () => { + if (!bodyLoaded || !accessoryLoaded) { + return; + } + const context = canvas.getContext('2d'); + if (!context) { + return; + } + context.imageSmoothingEnabled = false; + if (unlocked) { + drawChatPetComposite( + context, + bodyImage, + accessoryImage, + 0, + 0, + CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, + CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, + 'right', + 'idle', + undefined, + true, + accessory?.eyeAccessoryMirrorsWithFacing !== false, + accessory?.coversAntennae === true, + ); + return; + } + context.clearRect(0, 0, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE); + if (!accessoryImage) { + return; + } + drawChatPetAccessory(context, accessoryImage, 'idle', 0, 'right'); + context.globalCompositeOperation = 'source-in'; + const silhouetteColor = themeService.getColorTheme().getColor(foreground); + if (!silhouetteColor) { + context.clearRect(0, 0, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE); + context.globalCompositeOperation = 'source-over'; + return; + } + context.fillStyle = silhouetteColor.toString(); + context.fillRect(0, 0, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE); + context.globalCompositeOperation = 'source-over'; + }; + + if (unlocked) { + 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}`); + return; + } + bodyLoaded = true; + draw(); + })); + store.add(DOM.addDisposableListener(bodyImage, 'error', () => { + logService.error(`[ChatPetAchievementPreview] Failed to load preview body: ${bodySource}`); + })); + bodyImage.src = bodySource; + } + if (accessoryImage && accessorySource) { + store.add(DOM.addDisposableListener(accessoryImage, 'load', () => { + if (!hasChatPetAccessoryImageDimensions(accessoryImage, accessorySource)) { + logService.error(`[ChatPetAchievementPreview] Invalid preview accessory dimensions: ${accessorySource.url}`); + return; + } + accessoryLoaded = true; + draw(); + })); + store.add(DOM.addDisposableListener(accessoryImage, 'error', () => { + logService.error(`[ChatPetAchievementPreview] Failed to load preview accessory: ${accessorySource.url}`); + })); + accessoryImage.src = 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 new file mode 100644 index 00000000000000..92141a973cf2b7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts @@ -0,0 +1,292 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { timeout } from '../../../../base/common/async.js'; +import * as DOM from '../../../../base/browser/dom.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../../base/common/observable.js'; +import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { IMcpWorkbenchService } from '../../mcp/common/mcpTypes.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { EditorExtensions } from '../../../common/editor.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; +import { AICustomizationManagementSection } from '../common/aiCustomizationWorkspaceService.js'; +import { ICustomizationHarnessService } from '../common/customizationHarnessService.js'; +import { IAICustomizationItemSource, IAICustomizationListItem } from './aiCustomization/aiCustomizationItemSource.js'; +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 { ChatPetContextKeys, IChatPetService } from './chatPetService.js'; + +Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + ChatPetAchievementsEditor, + ChatPetAchievementsEditor.ID, + localize('chatPet.achievements.editor', "Achievements Editor"), + ), + [new SyncDescriptor(ChatPetAchievementsEditorInput)], +); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, + title: localize2('chatPet.achievements.open', "Open Achievements"), + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatPetContextKeys.enabled), + }); + + registerAction2(class extends Action2 { + constructor() { + super({ + id: 'chat.pet.developer.unlockAllAchievements', + title: localize2('chatPet.achievements.developer.unlockAll', "Unlock All Pet Achievements"), + category: Categories.Developer, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatPetContextKeys.enabled), + f1: true, + }); + } + + run(accessor: ServicesAccessor): void { + const chatPetService = accessor.get(IChatPetService); + for (const achievement of chatPetAchievements) { + chatPetService.unlockAchievement(achievement.id); + } + status(localize('chatPet.achievements.developer.unlockedAll', "All enabled pet achievements unlocked")); + } + }); + + registerAction2(class extends Action2 { + constructor() { + super({ + id: 'chat.pet.developer.resetAchievements', + title: localize2('chatPet.achievements.developer.reset', "Reset Pet Achievements"), + category: Categories.Developer, + precondition: ChatContextKeys.enabled, + f1: true, + }); + } + + run(accessor: ServicesAccessor): void { + accessor.get(IChatPetService).resetAchievements(); + status(localize('chatPet.achievements.developer.resetComplete', "Pet achievements reset")); + } + }); + } + + async run(accessor: ServicesAccessor): Promise<void> { + if (!accessor.get(IChatPetService).enabled.get()) { + return; + } + await accessor.get(IEditorService).openEditor(ChatPetAchievementsEditorInput.getOrCreate(), { pinned: true }); + } +}); + +export class ChatPetContextContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatPetContext'; + + constructor( + @IChatPetService chatPetService: IChatPetService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + const enabledContextKey = ChatPetContextKeys.enabled.bindTo(contextKeyService); + this._register(autorun(reader => { + enabledContextKey.set(chatPetService.enabled.read(reader)); + })); + } +} + +export class ChatPetCustomizationAchievementContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatPetCustomizationAchievements'; + private customizationBaselineVersion = 0; + private customizationBaselineReady = false; + private observedCustomizationSource: IAICustomizationItemSource | undefined; + private observedSkillIds = new Set<string>(); + private observedInstructionIds = new Set<string>(); + private observationInitializationStarted = false; + + constructor( + @IChatPetService private readonly chatPetService: IChatPetService, + @IAICustomizationItemsModel private readonly customizationItemsModel: IAICustomizationItemsModel, + @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, + @IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this._register(autorun(reader => { + if (!this.chatPetService.enabled.read(reader) || this.observationInitializationStarted) { + return; + } + this.observationInitializationStarted = true; + void this.initializeCustomizationObservation(); + void this.initializeMcpObservation(); + })); + } + + private async initializeCustomizationObservation(): Promise<void> { + const skills = this.customizationItemsModel.getItems(AICustomizationManagementSection.Skills); + const instructions = this.customizationItemsModel.getItems(AICustomizationManagementSection.Instructions); + let waitForLatestFetch = false; + while (!this._store.isDisposed) { + const source = this.customizationItemsModel.getActiveItemSource(); + if (await this.establishCustomizationBaseline(skills, instructions, source, waitForLatestFetch)) { + break; + } + waitForLatestFetch = true; + } + if (this._store.isDisposed) { + return; + } + + this._register(autorun(reader => { + this.customizationHarnessService.activeSessionResource.read(reader); + this.customizationHarnessService.availableHarnesses.read(reader); + const source = this.customizationItemsModel.getActiveItemSource(); + const currentSkillIds = getUserCustomizationIds(skills.read(reader)); + const currentInstructionIds = getUserCustomizationIds(instructions.read(reader)); + const enabled = this.chatPetService.enabled.read(reader); + const unlocked = new Set(this.chatPetService.unlockedAchievements.read(reader)); + if (source !== this.observedCustomizationSource) { + this.observedCustomizationSource = source; + this.customizationBaselineReady = false; + void this.establishCustomizationBaseline(skills, instructions, source, true); + return; + } + if (!this.customizationBaselineReady) { + return; + } + + const skillAdded = hasAddedId(currentSkillIds, this.observedSkillIds); + const instructionAdded = hasAddedId(currentInstructionIds, this.observedInstructionIds); + this.observedSkillIds = currentSkillIds; + this.observedInstructionIds = currentInstructionIds; + if (enabled && skillAdded && !unlocked.has(ChatPetAchievementIds.CustomSkillPresent)) { + this.chatPetService.unlockAchievement(ChatPetAchievementIds.CustomSkillPresent); + } + if (enabled && instructionAdded && !unlocked.has(ChatPetAchievementIds.InstructionPresent)) { + this.chatPetService.unlockAchievement(ChatPetAchievementIds.InstructionPresent); + } + })); + } + + private async establishCustomizationBaseline( + skills: IObservable<readonly IAICustomizationListItem[]>, + instructions: IObservable<readonly IAICustomizationListItem[]>, + expectedSource: IAICustomizationItemSource, + waitForLatestFetch: boolean, + ): Promise<boolean> { + const version = ++this.customizationBaselineVersion; + this.customizationBaselineReady = false; + if (waitForLatestFetch) { + await timeout(0); + } + await Promise.all([ + this.customizationItemsModel.whenSectionLoaded(AICustomizationManagementSection.Skills), + this.customizationItemsModel.whenSectionLoaded(AICustomizationManagementSection.Instructions), + ]); + if (this._store.isDisposed || version !== this.customizationBaselineVersion || expectedSource !== this.customizationItemsModel.getActiveItemSource()) { + return false; + } + + this.observedCustomizationSource = expectedSource; + this.observedSkillIds = getUserCustomizationIds(skills.get()); + this.observedInstructionIds = getUserCustomizationIds(instructions.get()); + this.customizationBaselineReady = true; + return true; + } + + private async initializeMcpObservation(): Promise<void> { + try { + await this.mcpWorkbenchService.queryLocal(); + } catch (error) { + this.logService.error('[ChatPetCustomizationAchievementContribution] Failed to establish the MCP server baseline', error); + return; + } + if (this._store.isDisposed) { + return; + } + + let observedServerIds = getMcpServerIds(this.mcpWorkbenchService); + this._register(this.mcpWorkbenchService.onChange(() => { + const currentServerIds = getMcpServerIds(this.mcpWorkbenchService); + const serverAdded = hasAddedId(currentServerIds, observedServerIds); + observedServerIds = currentServerIds; + if (this.chatPetService.enabled.get() + && serverAdded + && !this.chatPetService.unlockedAchievements.get().includes(ChatPetAchievementIds.McpServerPresent)) { + this.chatPetService.unlockAchievement(ChatPetAchievementIds.McpServerPresent); + } + })); + } +} + +function getUserCustomizationIds(items: readonly IAICustomizationListItem[]): Set<string> { + return new Set(items + .filter(item => isUserAuthoredChatPetCustomization(item.source, item.isBuiltin)) + .map(item => item.id)); +} + +function getMcpServerIds(mcpWorkbenchService: IMcpWorkbenchService): Set<string> { + return new Set(mcpWorkbenchService.local.map(server => server.id)); +} + +function hasAddedId(currentIds: ReadonlySet<string>, previousIds: ReadonlySet<string>): boolean { + for (const id of currentIds) { + if (!previousIds.has(id)) { + return true; + } + } + return false; +} + +export class ChatPetAchievementsAccessibilityHelp implements IAccessibleViewImplementation { + + readonly priority = 110; + readonly name = 'chatPetAchievements'; + readonly type = AccessibleViewType.Help; + readonly when = ChatPetAchievementsContextKeys.focused; + + getProvider(_accessor: ServicesAccessor): AccessibleContentProvider { + const previouslyFocusedElement = DOM.getActiveElement(); + const editorService = _accessor.get(IEditorService); + const content = [ + localize('chatPet.achievements.accessibilityHelp.overview', "The Achievements modal lists secret agent-feature achievements and the pet hats rewarded by unlocked achievements."), + 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 are announced as locked and cannot be selected."), + 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."), + ].join('\n\n'); + return new AccessibleContentProvider( + AccessibleViewProviderId.ChatPetAchievements, + { type: AccessibleViewType.Help }, + () => content, + () => { + if (DOM.isHTMLElement(previouslyFocusedElement) && previouslyFocusedElement.isConnected && previouslyFocusedElement.getClientRects().length > 0) { + previouslyFocusedElement.focus(); + } else { + editorService.activeEditorPane?.focus(); + } + }, + AccessibilityVerbositySettingId.ChatPetAchievements, + ); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts new file mode 100644 index 00000000000000..b59a888a06dbfa --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts @@ -0,0 +1,323 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import type { AICustomizationSource } from '../common/aiCustomizationWorkspaceService.js'; + +export const CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID = 'chat.pet.openAchievements'; + +export const ChatPetAchievementIds = { + RequestRevision: 'requestRevision', + FirstChatMessage: 'firstChatMessage', + ModelSwitch: 'modelSwitch', + QueueOrSteeringMessage: 'queueOrSteeringMessage', + AgentsWindowOpened: 'agentsWindowOpened', + IntegratedBrowserShared: 'integratedBrowserShared', + ChatOutputCopied: 'chatOutputCopied', + CustomSkillPresent: 'customSkillPresent', + McpServerPresent: 'mcpServerPresent', + InstructionPresent: 'instructionPresent', + ImageRequest: 'imageRequest', +} as const; + +export type ChatPetAchievementId = typeof ChatPetAchievementIds[keyof typeof ChatPetAchievementIds]; + +export const ChatPetAccessoryIds = { + CowboyHat: 'cowboyHat', + TopHatMonocle: 'topHatMonocle', + SailorHat: 'sailorHat', + BaseballCap: 'baseballCap', + PartyHat: 'partyHat', + SpinnerHat: 'spinnerHat', + ConstructionHardHat: 'constructionHardHat', + FirefighterHelmet: 'firefighterHelmet', + VikingHelmet: 'vikingHelmet', + Crown: 'crown', + ArtistBeret: 'artistBeret', +} as const; + +export type ChatPetAccessoryId = typeof ChatPetAccessoryIds[keyof typeof ChatPetAccessoryIds]; + +export interface IChatPetAccessory { + readonly id: ChatPetAccessoryId; + readonly label: string; + readonly atlasName: string; + readonly atlasCellSize?: 64 | 96; + readonly eyeAccessoryMirrorsWithFacing?: boolean; + readonly coversAntennae?: boolean; +} + +export interface IChatPetAchievement { + readonly id: ChatPetAchievementId; + readonly title: string; + readonly description: string; + readonly accessories: readonly [IChatPetAccessory, ...IChatPetAccessory[]]; + readonly enabled: boolean; +} + +export type ChatPetAchievementPresentation = + | { readonly locked: true; readonly id: ChatPetAchievementId } + | { readonly locked: false; readonly id: ChatPetAchievementId; readonly title: string; readonly description: string; readonly accessories: readonly IChatPetAccessory[] }; + +const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ + { + id: ChatPetAchievementIds.RequestRevision, + title: localize('chatPet.achievement.requestRevision.title', "Second Draft"), + description: localize('chatPet.achievement.requestRevision.description', "You edited and resent an earlier chat request."), + enabled: true, + accessories: [ + { + id: ChatPetAccessoryIds.TopHatMonocle, + label: localize('chatPet.accessory.topHatMonocle', "Grand Top Hat & Monocle"), + atlasName: 'grand-top-hat-monocle', + atlasCellSize: 96, + eyeAccessoryMirrorsWithFacing: false, + coversAntennae: true, + }, + ], + }, + { + id: ChatPetAchievementIds.FirstChatMessage, + title: localize('chatPet.achievement.firstChatMessage.title', "Welcome to the Wild West"), + description: localize('chatPet.achievement.firstChatMessage.description', "You sent your first chat message."), + enabled: true, + accessories: [ + { + id: ChatPetAccessoryIds.CowboyHat, + label: localize('chatPet.accessory.cowboyHat', "Cowboy Hat"), + atlasName: 'cowboy-hat', + atlasCellSize: 96, + coversAntennae: true, + }, + ], + }, + { + id: ChatPetAchievementIds.IntegratedBrowserShared, + title: localize('chatPet.achievement.integratedBrowserShared.title', "Shared Perspective"), + description: localize('chatPet.achievement.integratedBrowserShared.description', "You shared the integrated browser with the agent."), + enabled: true, + accessories: [ + { + id: ChatPetAccessoryIds.BaseballCap, + label: localize('chatPet.accessory.baseballCap', "Baseball Cap"), + atlasName: 'baseball-cap', + atlasCellSize: 96, + coversAntennae: true, + }, + ], + }, + { + id: ChatPetAchievementIds.ModelSwitch, + title: localize('chatPet.achievement.modelSwitch.title', "Model Citizen"), + description: localize('chatPet.achievement.modelSwitch.description', "You selected a different model from the model picker."), + enabled: true, + accessories: [ + { + id: ChatPetAccessoryIds.ConstructionHardHat, + label: localize('chatPet.accessory.constructionHardHat', "Construction Hard Hat"), + atlasName: 'construction-hard-hat', + atlasCellSize: 96, + coversAntennae: true, + }, + ], + }, + { + id: ChatPetAchievementIds.McpServerPresent, + title: localize('chatPet.achievement.mcpServerPresent.title', "Server Wrangler"), + description: localize('chatPet.achievement.mcpServerPresent.description', "You configured an MCP server."), + enabled: true, + accessories: [ + { + id: ChatPetAccessoryIds.FirefighterHelmet, + label: localize('chatPet.accessory.firefighterHelmet', "Firefighter Helmet"), + atlasName: 'firefighter-helmet', + atlasCellSize: 96, + coversAntennae: true, + }, + ], + }, + { + id: ChatPetAchievementIds.CustomSkillPresent, + title: localize('chatPet.achievement.customSkillPresent.title', "Skilled Builder"), + description: localize('chatPet.achievement.customSkillPresent.description', "You added a custom skill."), + enabled: true, + accessories: [ + { + id: ChatPetAccessoryIds.Crown, + label: localize('chatPet.accessory.crown', "Crown"), + atlasName: 'crown', + atlasCellSize: 96, + coversAntennae: true, + }, + ], + }, +]; + +export const disabledChatPetAchievements: readonly IChatPetAchievement[] = [ + { + id: ChatPetAchievementIds.InstructionPresent, + title: localize('chatPet.achievement.instructionPresent.title', "Well Instructed"), + description: localize('chatPet.achievement.instructionPresent.description', "You added custom instructions."), + enabled: false, + accessories: [{ + id: ChatPetAccessoryIds.SailorHat, + label: localize('chatPet.accessory.sailorHat', "Sailor Hat"), + atlasName: 'sailor-hat', + atlasCellSize: 96, + coversAntennae: true, + }], + }, + { + id: ChatPetAchievementIds.QueueOrSteeringMessage, + title: localize('chatPet.achievement.queueOrSteeringMessage.title', "Course Correction"), + description: localize('chatPet.achievement.queueOrSteeringMessage.description', "You queued or steered a follow-up message while chat was working."), + enabled: false, + accessories: [{ + id: ChatPetAccessoryIds.SpinnerHat, + label: localize('chatPet.accessory.spinnerHat', "Full-Size Spinner Hat"), + atlasName: 'full-size-spinner-hat', + atlasCellSize: 96, + coversAntennae: true, + }], + }, + { + id: ChatPetAchievementIds.AgentsWindowOpened, + title: localize('chatPet.achievement.agentsWindowOpened.title', "Mission Control"), + description: localize('chatPet.achievement.agentsWindowOpened.description', "You opened the Agents window."), + enabled: false, + accessories: [{ + id: ChatPetAccessoryIds.VikingHelmet, + label: localize('chatPet.accessory.vikingHelmet', "Viking Helmet"), + atlasName: 'viking-helmet', + atlasCellSize: 96, + coversAntennae: true, + }], + }, + { + id: ChatPetAchievementIds.ChatOutputCopied, + title: localize('chatPet.achievement.chatOutputCopied.title', "Copy That"), + description: localize('chatPet.achievement.chatOutputCopied.description', "You copied output from chat."), + enabled: false, + accessories: [{ + id: ChatPetAccessoryIds.PartyHat, + label: localize('chatPet.accessory.partyHat', "Leaning Party Hat"), + atlasName: 'leaning-party-hat', + atlasCellSize: 96, + coversAntennae: true, + }], + }, + { + id: ChatPetAchievementIds.ImageRequest, + title: localize('chatPet.achievement.imageRequest.title', "Picture This"), + description: localize('chatPet.achievement.imageRequest.description', "You sent a chat request with an image attached."), + enabled: false, + accessories: [{ + id: ChatPetAccessoryIds.ArtistBeret, + label: localize('chatPet.accessory.artistBeret', "Artist Beret"), + atlasName: 'artist-beret', + atlasCellSize: 96, + coversAntennae: true, + }], + }, +]; + +export const allChatPetAchievements: readonly IChatPetAchievement[] = [...enabledChatPetAchievements, ...disabledChatPetAchievements]; +export const chatPetAchievements: readonly IChatPetAchievement[] = allChatPetAchievements.filter(achievement => achievement.enabled); + +const chatPetAchievementIds = new Set<string>(chatPetAchievements.map(achievement => achievement.id)); +const allChatPetAchievementIds = new Set<string>(allChatPetAchievements.map(achievement => achievement.id)); +export const chatPetAccessories: readonly IChatPetAccessory[] = chatPetAchievements.flatMap(achievement => achievement.accessories); +export const allChatPetAccessories: readonly IChatPetAccessory[] = allChatPetAchievements.flatMap(achievement => achievement.accessories); +const chatPetAccessoryById = new Map(chatPetAccessories.map(accessory => [accessory.id, accessory])); +const allChatPetAccessoryById = new Map(allChatPetAccessories.map(accessory => [accessory.id, accessory])); + +export function isChatPetAchievementId(value: string): value is ChatPetAchievementId { + return allChatPetAchievementIds.has(value); +} + +export function isChatPetAchievementEnabled(id: ChatPetAchievementId): boolean { + return chatPetAchievementIds.has(id); +} + +export function isChatPetAccessoryId(value: string): value is ChatPetAccessoryId { + return allChatPetAccessoryById.has(value as ChatPetAccessoryId); +} + +export function getChatPetAchievement(id: ChatPetAchievementId): IChatPetAchievement { + const achievement = chatPetAchievements.find(candidate => candidate.id === id); + if (!achievement) { + throw new Error(`Unknown chat pet achievement: ${id}`); + } + return achievement; +} + +export function getChatPetAccessory(id: ChatPetAccessoryId): IChatPetAccessory { + const accessory = chatPetAccessoryById.get(id); + if (!accessory) { + throw new Error(`Unknown chat pet accessory: ${id}`); + } + return accessory; +} + +export function getChatPetAchievementForAccessory(id: ChatPetAccessoryId): IChatPetAchievement { + const achievement = allChatPetAchievements.find(candidate => candidate.accessories.some(accessory => accessory.id === id)); + if (!achievement) { + throw new Error(`No chat pet achievement rewards accessory: ${id}`); + } + return achievement; +} + +export function didExplicitlySwitchChatPetModel(previousModelIdentifier: string | undefined, selectedModelIdentifier: string): boolean { + return previousModelIdentifier !== undefined && previousModelIdentifier !== selectedModelIdentifier; +} + +export function hasChatPetImageAttachment(entries: readonly { readonly kind: string }[]): boolean { + return entries.some(entry => entry.kind === 'image'); +} + +export function shouldUnlockChatPetIntegratedBrowserShare(shared: boolean, succeeded: boolean): boolean { + return shared && succeeded; +} + +export function isUserAuthoredChatPetCustomization(source: AICustomizationSource, isBuiltin: boolean | undefined): boolean { + return !isBuiltin && (source === 'local' || source === 'user'); +} + +export function getChatPetCustomizationAchievementIds( + skills: readonly { readonly source: AICustomizationSource; readonly isBuiltin?: boolean }[], + instructions: readonly { readonly source: AICustomizationSource; readonly isBuiltin?: boolean }[], + mcpServerCount: number, +): readonly ChatPetAchievementId[] { + const achievements: ChatPetAchievementId[] = []; + if (skills.some(item => isUserAuthoredChatPetCustomization(item.source, item.isBuiltin))) { + achievements.push(ChatPetAchievementIds.CustomSkillPresent); + } + if (instructions.some(item => isUserAuthoredChatPetCustomization(item.source, item.isBuiltin))) { + achievements.push(ChatPetAchievementIds.InstructionPresent); + } + if (mcpServerCount > 0) { + achievements.push(ChatPetAchievementIds.McpServerPresent); + } + return achievements; +} + +export function getChatPetAchievementPresentation(achievement: IChatPetAchievement, unlocked: boolean): ChatPetAchievementPresentation { + return unlocked + ? { + locked: false, + id: achievement.id, + title: achievement.title, + description: achievement.description, + accessories: achievement.accessories, + } + : { locked: true, id: achievement.id }; +} + +export function getUnlockedChatPetAccessories(unlockedAchievements: readonly ChatPetAchievementId[]): readonly IChatPetAccessory[] { + const unlocked = new Set(unlockedAchievements); + return chatPetAchievements + .filter(achievement => unlocked.has(achievement.id)) + .flatMap(achievement => achievement.accessories); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts new file mode 100644 index 00000000000000..c8e6cbffeb055f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.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'; +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 { ChatPetAchievementsWidget } from './chatPetAchievementsWidget.js'; +import { ChatPetAchievementsEditorInput } from './chatPetAchievementsEditorInput.js'; +import { IChatPetService } from './chatPetService.js'; + +export const ChatPetAchievementsContextKeys = { + focused: new RawContextKey<boolean>('chatPetAchievementsFocused', false, localize('chatPet.achievements.context.focused', "Whether the chat pet Achievements modal is focused")), +}; + +export class ChatPetAchievementsEditor extends EditorPane { + + static readonly ID = 'workbench.editor.chatPetAchievements'; + + private readonly editorDisposables = this._register(new DisposableStore()); + private readonly focusedContextKey: IContextKey<boolean>; + private container: HTMLElement | undefined; + private widget: ChatPetAchievementsWidget | undefined; + private dimension: DOM.Dimension | undefined; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IChatPetService private readonly chatPetService: IChatPetService, + ) { + 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) { + void this.group.closeEditor(this.input); + } + })); + } + + protected override createEditor(parent: HTMLElement): void { + this.editorDisposables.clear(); + this.container = DOM.append(parent, DOM.$('.chat-pet-achievements-editor')); + 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); + } + })); + } + + override async setInput(input: ChatPetAchievementsEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> { + await super.setInput(input, options, context, token); + if (!this.chatPetService.enabled.get()) { + await this.group.closeEditor(input); + return; + } + if (this.dimension) { + this.layout(this.dimension); + } + } + + override clearInput(): void { + this.focusedContextKey.set(false); + super.clearInput(); + } + + override layout(dimension: DOM.Dimension): void { + this.dimension = dimension; + if (this.container) { + this.container.style.width = `${dimension.width}px`; + this.container.style.height = `${dimension.height}px`; + } + this.widget?.layout(dimension); + } + + override focus(): void { + super.focus(); + this.widget?.focus(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.ts new file mode 100644 index 00000000000000..0ba4090aeda479 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.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 { 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 { EditorInputCapabilities, IUntypedEditorInput } from '../../../common/editor.js'; +import { EditorInput } from '../../../common/editor/editorInput.js'; + +export class ChatPetAchievementsEditorInput extends EditorInput implements IModalEditorOptionsProvider { + + static readonly ID = 'workbench.editors.chatPetAchievements'; + private static instance: ChatPetAchievementsEditorInput | undefined; + + readonly resource = undefined; + + static getOrCreate(): ChatPetAchievementsEditorInput { + if (!ChatPetAchievementsEditorInput.instance || ChatPetAchievementsEditorInput.instance.isDisposed()) { + ChatPetAchievementsEditorInput.instance = new ChatPetAchievementsEditorInput(); + } + return ChatPetAchievementsEditorInput.instance; + } + + override get capabilities(): EditorInputCapabilities { + return super.capabilities | EditorInputCapabilities.Singleton | EditorInputCapabilities.RequiresModal; + } + + override get typeId(): string { + return ChatPetAchievementsEditorInput.ID; + } + + override getName(): string { + return localize('chatPet.achievements.editorName', "Achievements"); + } + + override getIcon(): ThemeIcon { + return Codicon.starFull; + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + return super.matches(otherInput) || otherInput instanceof ChatPetAchievementsEditorInput; + } + + getModalEditorOptions(): IModalEditorOptions { + return { compactHeader: true }; + } + + override async resolve(): Promise<null> { + return null; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts new file mode 100644 index 00000000000000..a57dca937d9d86 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts @@ -0,0 +1,295 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { status } from '../../../../base/browser/ui/aria/aria.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; +import { localize } from '../../../../nls.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'; + +export class ChatPetAchievementsWidget extends Disposable { + + private readonly container: HTMLElement; + private readonly content: HTMLElement; + private readonly scrollable: DomScrollableElement; + private readonly renderDisposables = this._register(new DisposableStore()); + private readonly accessoryCards = new Map<string, { + readonly button: Button; + readonly state: HTMLElement; + readonly defaultState: string; + readonly selectedAriaLabel: string; + readonly defaultAriaLabel: string; + readonly achievementId?: ChatPetAchievementId; + readonly newBadge?: HTMLElement; + }>(); + private unseenAchievementIds = new Set<ChatPetAchievementId>(); + private focusTarget: (() => void) | undefined; + private lastDimension: DOM.Dimension | undefined; + + constructor( + parent: HTMLElement, + private readonly 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-achievements-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, () => { + const scrollTop = this.content.scrollTop; + if (scrollTop !== this.scrollable.getScrollPosition().scrollTop) { + this.scrollable.setScrollPosition({ scrollTop }); + } + }, { passive: true })); + + 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); + themeChanged.read(reader); + this.render(unlockedAchievements, this.chatPetService.selectedAccessory.read(undefined), variant); + })); + this._register(autorun(reader => { + this.updateSelectedAccessory(this.chatPetService.selectedAccessory.read(reader)); + })); + this._register(autorun(reader => { + this.updateNewAchievements(this.chatPetService.unseenAchievements.read(reader)); + })); + } + + layout(dimension?: DOM.Dimension): void { + if (dimension) { + this.lastDimension = dimension; + this.container.style.width = `${dimension.width}px`; + this.container.style.height = `${dimension.height}px`; + } + const width = dimension?.width ?? this.container.clientWidth; + const height = dimension?.height ?? this.container.clientHeight; + this.container.classList.toggle('narrow', width < 560); + this.content.style.width = `${width}px`; + this.content.style.height = `${height}px`; + const scrollableNode = this.scrollable.getDomNode(); + scrollableNode.style.width = `${width}px`; + scrollableNode.style.height = `${height}px`; + this.scrollable.scanDomNode(); + } + + focus(): void { + this.focusTarget?.(); + } + + private render(unlockedAchievements: readonly ChatPetAchievementId[], selectedAccessory: ChatPetAccessoryId | undefined, variant: ChatPetVariant): void { + const activeElement = DOM.getActiveElement(); + const restoreFocusId = DOM.isHTMLElement(activeElement) ? activeElement.closest<HTMLElement>('.chat-pet-achievement-card')?.dataset.accessoryId : undefined; + this.renderDisposables.clear(); + DOM.clearNode(this.content); + this.accessoryCards.clear(); + this.focusTarget = undefined; + + 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."); + const unlockedSet = new Set(unlockedAchievements); + const collection = DOM.append(inner, DOM.$('section.chat-pet-achievements-collection')); + collection.setAttribute('role', 'region'); + collection.setAttribute('aria-label', localize( + 'chatPet.achievements.collectionAriaLabel', + "Achievement collection, {0} of {1} unlocked", + unlockedAchievements.length, + chatPetAchievements.length + )); + const collectionHeader = DOM.append(collection, DOM.$('.chat-pet-achievements-collection-header')); + DOM.append(collectionHeader, DOM.$('h2')).textContent = localize('chatPet.achievements.collection', "Collection"); + DOM.append(collectionHeader, DOM.$('span.chat-pet-achievements-count')).textContent = localize( + 'chatPet.achievements.count', + "{0} of {1} unlocked", + unlockedAchievements.length, + chatPetAchievements.length + ); + + const list = DOM.append(collection, DOM.$('ul.chat-pet-achievements-list')); + const cards = new Map<string, Button>(); + const noHatId = 'none'; + const noHatItem = DOM.append(list, DOM.$('li.chat-pet-achievements-list-item')); + const noHatSelected = selectedAccessory === undefined; + const noHatCard = this.renderDisposables.add(new Button(noHatItem, { + secondary: true, + ariaLabel: noHatSelected + ? localize('chatPet.achievement.noHatSelected', "No Hat, selected") + : localize('chatPet.achievement.noHat', "No Hat"), + })); + noHatCard.element.classList.add('chat-pet-achievement-card', 'no-hat'); + noHatCard.element.dataset.accessoryId = noHatId; + 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); + 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')); + noHatState.textContent = noHatSelected + ? localize('chatPet.achievement.wearing', "Wearing") + : localize('chatPet.achievement.select', "Select"); + this.renderDisposables.add(noHatCard.onDidClick(() => this.selectAccessory(undefined))); + this.renderDisposables.add(noHatCard.onDidEscape(() => this.onDidRequestClose())); + this.accessoryCards.set(noHatId, { + button: noHatCard, + state: noHatState, + defaultState: localize('chatPet.achievement.select', "Select"), + selectedAriaLabel: localize('chatPet.achievement.noHatSelected', "No Hat, selected"), + defaultAriaLabel: localize('chatPet.achievement.noHat', "No Hat"), + }); + cards.set(noHatId, noHatCard); + + for (const achievement of chatPetAchievements) { + const unlocked = unlockedSet.has(achievement.id); + 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 card = this.renderDisposables.add(new Button(item, { + secondary: true, + ariaLabel: unlocked + ? localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", achievement.title, achievement.accessories[0].label, wearing ? localize('chatPet.achievement.wearing', "Wearing") : localize('chatPet.achievement.unlocked', "Unlocked")) + : localize('chatPet.achievement.lockedAriaLabel', "Locked secret achievement"), + })); + card.element.classList.add('chat-pet-achievement-card'); + card.element.dataset.accessoryId = accessoryId; + 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); + } + + const cardContent = DOM.append(card.element, DOM.$('.chat-pet-achievement-card-content')); + if (!presentation.locked) { + DOM.append(cardContent, DOM.$('h3')).textContent = presentation.title; + const state = DOM.append(cardContent, DOM.$('span.chat-pet-achievement-state')); + state.textContent = wearing + ? localize('chatPet.achievement.wearing', "Wearing") + : localize('chatPet.achievement.unlocked', "Unlocked"); + 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, { + 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")), + achievementId: achievement.id, + newBadge, + }); + } else { + DOM.append(cardContent, DOM.$('h3')).textContent = localize('chatPet.achievement.locked', "Locked"); + DOM.append(cardContent, DOM.$('p.chat-pet-achievement-secret')).textContent = localize('chatPet.achievement.secret', "Secret achievement."); + DOM.append(cardContent, DOM.$('p.chat-pet-achievement-description')).textContent = localize('chatPet.achievement.keepExploring', "Keep exploring agent features to uncover this secret."); + } + this.renderDisposables.add(card.onDidClick(() => this.selectAccessory(accessoryId, achievement.id))); + this.renderDisposables.add(card.onDidEscape(() => this.onDidRequestClose())); + cards.set(accessoryId, card); + } + + const roadmapItem = DOM.append(list, DOM.$('li.chat-pet-achievements-list-item')); + const roadmapCard = DOM.append(roadmapItem, DOM.$('article.chat-pet-achievement-card.chat-pet-achievement-roadmap')); + const roadmapPreview = DOM.append(roadmapCard, DOM.$('.chat-pet-achievement-roadmap-preview')); + roadmapPreview.textContent = localize('chatPet.achievements.roadmap.preview', "TBD"); + const roadmapContent = DOM.append(roadmapCard, DOM.$('.chat-pet-achievement-card-content')); + DOM.append(roadmapContent, DOM.$('h3')).textContent = localize('chatPet.achievements.roadmap.title', "TBD"); + DOM.append(roadmapContent, DOM.$('span.chat-pet-achievement-state')).textContent = localize('chatPet.achievements.roadmap.state', "Coming soon"); + const roadmapIntro = DOM.append(roadmapContent, DOM.$('p.chat-pet-achievement-description')); + 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"), + ]) { + DOM.append(roadmapList, DOM.$('li')).textContent = item; + } + + const experimentalNote = DOM.append(inner, DOM.$('p.chat-pet-achievements-experimental')); + experimentalNote.textContent = localize('chatPet.achievements.experimental', "The VS Code pet and achievements are experimental. Features and rewards may change."); + + this.scrollable.scanDomNode(); + this.layout(this.lastDimension); + this.updateNewAchievements(this.chatPetService.unseenAchievements.read(undefined)); + const selectedCardId = selectedAccessory ?? noHatId; + this.focusTarget = () => cards.get(selectedCardId)?.focus(); + if (restoreFocusId) { + queueMicrotask(() => { + if (!this._store.isDisposed) { + cards.get(restoreFocusId)?.focus(); + } + }); + } + } + + private renderCardPreview(previews: HTMLElement, accessory: Parameters<typeof renderChatPetAchievementPreview>[1], unlocked: boolean, variant: ChatPetVariant): 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)); + } + + private selectAccessory(accessoryId: ChatPetAccessoryId | undefined, achievementId?: ChatPetAchievementId): void { + if (achievementId) { + this.chatPetService.markAchievementSeen(achievementId); + } + if (accessoryId === this.chatPetService.selectedAccessory.get()) { + return; + } + this.chatPetService.setAccessory(accessoryId); + status(accessoryId === undefined + ? localize('chatPet.achievements.hatRemoved', "VS Code pet hat removed") + : localize('chatPet.achievements.hatSelected', "VS Code pet is now wearing {0}", getChatPetAccessory(accessoryId).label)); + } + + private updateSelectedAccessory(accessoryId: ChatPetAccessoryId | undefined): void { + const selectedId = accessoryId ?? 'none'; + 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)); + const baseAriaLabel = wearing ? card.selectedAriaLabel : card.defaultAriaLabel; + card.button.setAriaLabel(card.achievementId && this.unseenAchievementIds.has(card.achievementId) + ? localize('chatPet.achievement.cardNewAriaLabel', "{0}. New", baseAriaLabel) + : baseAriaLabel); + card.state.textContent = wearing ? localize('chatPet.achievement.wearing', "Wearing") : card.defaultState; + } + this.focusTarget = () => this.accessoryCards.get(selectedId)?.button.focus(); + } + + private updateNewAchievements(achievementIds: readonly ChatPetAchievementId[]): void { + this.unseenAchievementIds = new Set(achievementIds); + for (const card of this.accessoryCards.values()) { + card.newBadge?.classList.toggle('hidden', !card.achievementId || !this.unseenAchievementIds.has(card.achievementId)); + } + this.updateSelectedAccessory(this.chatPetService.selectedAccessory.read(undefined)); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatPetService.ts b/src/vs/workbench/contrib/chat/browser/chatPetService.ts index b1828118b5ac50..8383aacd807449 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetService.ts @@ -4,23 +4,37 @@ *--------------------------------------------------------------------------------------------*/ import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; 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'; const CHAT_PET_ENABLED_STORAGE_KEY = 'chat.vscodePet.enabled'; const CHAT_PET_VARIANT_STORAGE_KEY = 'chat.vscodePet.variant'; 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.'; +const CHAT_PET_ACHIEVEMENT_CATALOG_VERSION_STORAGE_KEY = 'chat.vscodePet.achievementCatalogVersion'; +const CHAT_PET_ACHIEVEMENT_CATALOG_VERSION = 4; +const CHAT_PET_LOCAL_ACHIEVEMENT_MIGRATION_VERSION_STORAGE_KEY = 'chat.vscodePet.localAchievementMigrationVersion'; +const CHAT_PET_LOCAL_ACHIEVEMENT_MIGRATION_VERSION = 1; const CHAT_PET_SCALE_STORAGE_KEY = 'chat.vscodePet.scale'; const CHAT_PET_HORIZONTAL_POSITION_STORAGE_KEY = 'chat.vscodePet.horizontalPosition'; const CHAT_PET_DEFAULT_SCALE = 1; export type ChatPetVariant = 'stable' | 'insiders'; +export const ChatPetContextKeys = { + enabled: new RawContextKey<boolean>('chatPetEnabled', false, localize('chatPet.context.enabled', "Whether the VS Code pet is enabled")), +}; + type ChatPetEnablementEvent = { enabled: boolean; source: 'startup' | 'change'; @@ -58,11 +72,19 @@ export interface IChatPetService { readonly variant: IObservable<ChatPetVariant>; readonly onTheRun: IObservable<boolean>; readonly scale: IObservable<number>; + readonly unlockedAchievements: IObservable<readonly ChatPetAchievementId[]>; + readonly unseenAchievements: IObservable<readonly ChatPetAchievementId[]>; + readonly selectedAccessory: IObservable<ChatPetAccessoryId | undefined>; + readonly onDidUnlockAchievement: Event<ChatPetAchievementId>; readonly horizontalPosition: IObservable<number | undefined>; toggle(): boolean; setVariant(variant: ChatPetVariant): void; setOnTheRun(onTheRun: boolean): void; setScale(scale: number): void; + unlockAchievement(id: ChatPetAchievementId): boolean; + markAchievementSeen(id: ChatPetAchievementId): boolean; + setAccessory(id: ChatPetAccessoryId | undefined): void; + resetAchievements(): void; setHorizontalPosition(position: number): void; } @@ -78,15 +100,27 @@ export class ChatPetService extends Disposable implements IChatPetService { readonly onTheRun: IObservable<boolean>; private readonly _scale; readonly scale: IObservable<number>; + private readonly _unlockedAchievements; + readonly unlockedAchievements: IObservable<readonly ChatPetAchievementId[]>; + private readonly _unseenAchievements; + readonly unseenAchievements: IObservable<readonly ChatPetAchievementId[]>; + private readonly _selectedAccessory; + readonly selectedAccessory: IObservable<ChatPetAccessoryId | undefined>; + private readonly _onDidUnlockAchievement = this._register(new Emitter<ChatPetAchievementId>()); + readonly onDidUnlockAchievement = this._onDidUnlockAchievement.event; + private lastInvalidStoredAccessory: string | undefined; + private locallyUnlockingAchievement = false; private readonly _horizontalPosition; readonly horizontalPosition: IObservable<number | undefined>; constructor( @IStorageService private readonly storageService: IStorageService, @ITelemetryService private readonly telemetryService: ITelemetryService, + @ILogService private readonly logService: ILogService, ) { super(); + 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)); @@ -95,6 +129,12 @@ export class ChatPetService extends Disposable implements IChatPetService { this.onTheRun = this._onTheRun; this._scale = observableValue(this, getChatPetScale(this.storageService.get(CHAT_PET_SCALE_STORAGE_KEY, StorageScope.APPLICATION))); this.scale = this._scale; + this._unlockedAchievements = observableValue<readonly ChatPetAchievementId[]>(this, this._readUnlockedAchievements()); + this.unlockedAchievements = this._unlockedAchievements; + this._unseenAchievements = observableValue<readonly ChatPetAchievementId[]>(this, this._readUnseenAchievements()); + this.unseenAchievements = this._unseenAchievements; + this._selectedAccessory = observableValue<ChatPetAccessoryId | undefined>(this, this._readSelectedAccessory()); + this.selectedAccessory = this._selectedAccessory; this._horizontalPosition = observableValue(this, getChatPetHorizontalPosition(this.storageService.get(CHAT_PET_HORIZONTAL_POSITION_STORAGE_KEY, StorageScope.APPLICATION))); this.horizontalPosition = this._horizontalPosition; @@ -107,6 +147,17 @@ export class ChatPetService extends Disposable implements IChatPetService { 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); })); + for (const achievement of allChatPetAchievements) { + this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION_SHARED, this._getAchievementStorageKey(achievement.id), this._store)(() => { + this._refreshAchievementState(!this.locallyUnlockingAchievement); + })); + this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION_SHARED, this._getAchievementSeenStorageKey(achievement.id), this._store)(() => { + this._refreshUnseenAchievementState(); + })); + } + this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION_SHARED, CHAT_PET_ACCESSORY_STORAGE_KEY, this._store)(() => { + this._refreshSelectedAccessory(); + })); this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, CHAT_PET_SCALE_STORAGE_KEY, this._store)(() => { this._scale.set(getChatPetScale(this.storageService.get(CHAT_PET_SCALE_STORAGE_KEY, StorageScope.APPLICATION)), undefined); })); @@ -164,4 +215,228 @@ export class ChatPetService extends Disposable implements IChatPetService { this._horizontalPosition.set(normalizedPosition, undefined); this.storageService.store(CHAT_PET_HORIZONTAL_POSITION_STORAGE_KEY, normalizedPosition, StorageScope.APPLICATION, StorageTarget.MACHINE); } + + unlockAchievement(id: ChatPetAchievementId): boolean { + if (!isChatPetAchievementId(id)) { + throw new Error(`Unknown chat pet achievement: ${id}`); + } + if (!isChatPetAchievementEnabled(id)) { + return false; + } + if (!this._enabled.get()) { + return false; + } + if (this._isAchievementStored(id)) { + this._refreshAchievementState(); + return false; + } + + this.locallyUnlockingAchievement = true; + try { + this.storageService.store(this._getAchievementStorageKey(id), true, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + } finally { + this.locallyUnlockingAchievement = false; + } + this._refreshAchievementState(); + this._onDidUnlockAchievement.fire(id); + return true; + } + + markAchievementSeen(id: ChatPetAchievementId): boolean { + if (!this._unseenAchievements.get().includes(id)) { + return false; + } + this.storageService.store(this._getAchievementSeenStorageKey(id), true, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + this._refreshUnseenAchievementState(); + return true; + } + + setAccessory(id: ChatPetAccessoryId | undefined): void { + if (id !== undefined) { + if (!isChatPetAccessoryId(id)) { + throw new Error(`Unknown chat pet accessory: ${id}`); + } + const achievement = getChatPetAchievementForAccessory(id); + if (!achievement.enabled) { + throw new Error(`Chat pet accessory is disabled: ${id}`); + } + if (!this._unlockedAchievements.get().includes(achievement.id)) { + throw new Error(`Chat pet accessory is locked: ${id}`); + } + this.storageService.store(CHAT_PET_ACCESSORY_STORAGE_KEY, id, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + } else { + this.storageService.remove(CHAT_PET_ACCESSORY_STORAGE_KEY, StorageScope.APPLICATION_SHARED); + this.storageService.remove(CHAT_PET_ACCESSORY_STORAGE_KEY, StorageScope.APPLICATION); + } + this.lastInvalidStoredAccessory = undefined; + this._selectedAccessory.set(id, undefined); + } + + resetAchievements(): void { + this.locallyUnlockingAchievement = true; + try { + for (const achievement of allChatPetAchievements) { + const key = this._getAchievementStorageKey(achievement.id); + this.storageService.remove(key, StorageScope.APPLICATION_SHARED); + this.storageService.remove(key, StorageScope.APPLICATION); + const seenKey = this._getAchievementSeenStorageKey(achievement.id); + this.storageService.remove(seenKey, StorageScope.APPLICATION_SHARED); + this.storageService.remove(seenKey, StorageScope.APPLICATION); + } + this.storageService.remove('chat.vscodePet.achievement.checkpointRestore', StorageScope.APPLICATION_SHARED); + this.storageService.remove('chat.vscodePet.achievement.checkpointRestore', StorageScope.APPLICATION); + this.storageService.remove('chat.vscodePet.achievement.integratedBrowserOpened', StorageScope.APPLICATION_SHARED); + this.storageService.remove('chat.vscodePet.achievement.integratedBrowserOpened', StorageScope.APPLICATION); + this.storageService.remove('chat.vscodePet.achievement.chatFork', StorageScope.APPLICATION_SHARED); + this.storageService.remove('chat.vscodePet.achievement.chatFork', StorageScope.APPLICATION); + this.storageService.remove(CHAT_PET_ACCESSORY_STORAGE_KEY, StorageScope.APPLICATION_SHARED); + this.storageService.remove(CHAT_PET_ACCESSORY_STORAGE_KEY, StorageScope.APPLICATION); + } finally { + this.locallyUnlockingAchievement = false; + } + transaction(tx => { + this._unlockedAchievements.set([], tx); + this._unseenAchievements.set([], tx); + this._selectedAccessory.set(undefined, tx); + }); + } + + private _getAchievementStorageKey(id: ChatPetAchievementId): string { + return `chat.vscodePet.achievement.${id}`; + } + + private _getAchievementSeenStorageKey(id: ChatPetAchievementId): string { + return `${CHAT_PET_ACHIEVEMENT_SEEN_STORAGE_PREFIX}${id}`; + } + + private _migrateAchievementStorage(): void { + this._migrateLocalAchievementStorage(); + + const version = this.storageService.getNumber(CHAT_PET_ACHIEVEMENT_CATALOG_VERSION_STORAGE_KEY, StorageScope.APPLICATION_SHARED, 0); + if (version >= CHAT_PET_ACHIEVEMENT_CATALOG_VERSION) { + return; + } + + const wasUnlocked = (id: string) => this.storageService.getBoolean(`chat.vscodePet.achievement.${id}`, StorageScope.APPLICATION_SHARED, false); + this._storeLegacyAchievementRewards( + wasUnlocked('checkpointRestore'), + wasUnlocked('chatFork'), + wasUnlocked(ChatPetAchievementIds.RequestRevision), + wasUnlocked(ChatPetAchievementIds.ModelSwitch), + ); + this.storageService.store(CHAT_PET_ACHIEVEMENT_CATALOG_VERSION_STORAGE_KEY, CHAT_PET_ACHIEVEMENT_CATALOG_VERSION, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + } + + private _migrateLocalAchievementStorage(): void { + const version = this.storageService.getNumber(CHAT_PET_LOCAL_ACHIEVEMENT_MIGRATION_VERSION_STORAGE_KEY, StorageScope.APPLICATION, 0); + if (version >= CHAT_PET_LOCAL_ACHIEVEMENT_MIGRATION_VERSION) { + return; + } + + const localUnlockedAchievements = allChatPetAchievements + .filter(achievement => this.storageService.getBoolean(this._getAchievementStorageKey(achievement.id), StorageScope.APPLICATION, false)) + .map(achievement => achievement.id); + const checkpointRestoreUnlocked = this.storageService.getBoolean('chat.vscodePet.achievement.checkpointRestore', StorageScope.APPLICATION, false); + const chatForkUnlocked = this.storageService.getBoolean('chat.vscodePet.achievement.chatFork', StorageScope.APPLICATION, false); + const localAccessory = this.storageService.get(CHAT_PET_ACCESSORY_STORAGE_KEY, StorageScope.APPLICATION); + + for (const id of localUnlockedAchievements) { + this.storageService.store(this._getAchievementStorageKey(id), true, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + } + this._storeLegacyAchievementRewards( + checkpointRestoreUnlocked, + chatForkUnlocked, + localUnlockedAchievements.includes(ChatPetAchievementIds.RequestRevision), + localUnlockedAchievements.includes(ChatPetAchievementIds.ModelSwitch), + ); + if (localAccessory !== undefined && this.storageService.get(CHAT_PET_ACCESSORY_STORAGE_KEY, StorageScope.APPLICATION_SHARED) === undefined) { + this.storageService.store(CHAT_PET_ACCESSORY_STORAGE_KEY, localAccessory, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + } + this.storageService.store(CHAT_PET_LOCAL_ACHIEVEMENT_MIGRATION_VERSION_STORAGE_KEY, CHAT_PET_LOCAL_ACHIEVEMENT_MIGRATION_VERSION, StorageScope.APPLICATION, StorageTarget.USER); + } + + private _storeLegacyAchievementRewards(checkpointRestoreUnlocked: boolean, chatForkUnlocked: boolean, requestRevisionUnlocked: boolean, modelSwitchUnlocked: boolean): void { + if (checkpointRestoreUnlocked || chatForkUnlocked) { + this.storageService.store(this._getAchievementStorageKey(ChatPetAchievementIds.FirstChatMessage), true, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + } + if (requestRevisionUnlocked) { + this.storageService.store(this._getAchievementStorageKey(ChatPetAchievementIds.ChatOutputCopied), true, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + } + if (modelSwitchUnlocked) { + this.storageService.store(this._getAchievementStorageKey(ChatPetAchievementIds.QueueOrSteeringMessage), true, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + } + } + + private _readUnlockedAchievements(): readonly ChatPetAchievementId[] { + return chatPetAchievements + .filter(achievement => this._isAchievementStored(achievement.id)) + .map(achievement => achievement.id); + } + + private _isAchievementStored(id: ChatPetAchievementId): boolean { + const achievementKey = this._getAchievementStorageKey(id); + return this.storageService.getBoolean(achievementKey, StorageScope.APPLICATION_SHARED, false) + || this.storageService.getBoolean(achievementKey, StorageScope.APPLICATION, false); + } + + private _refreshAchievementState(fireUnlockEvents = false): void { + const previousAchievements = this._unlockedAchievements.get(); + const unlockedAchievements = this._readUnlockedAchievements(); + if (!this._haveSameAchievements(previousAchievements, unlockedAchievements)) { + this._unlockedAchievements.set(unlockedAchievements, undefined); + if (fireUnlockEvents) { + const previous = new Set(previousAchievements); + for (const id of unlockedAchievements) { + if (!previous.has(id)) { + this._onDidUnlockAchievement.fire(id); + } + } + } + } + this._refreshUnseenAchievementState(); + this._refreshSelectedAccessory(); + } + + private _readUnseenAchievements(): readonly ChatPetAchievementId[] { + const unlocked = new Set(this._unlockedAchievements.get()); + return chatPetAchievements + .filter(achievement => unlocked.has(achievement.id) && !this.storageService.getBoolean(this._getAchievementSeenStorageKey(achievement.id), StorageScope.APPLICATION_SHARED, false)) + .map(achievement => achievement.id); + } + + private _refreshUnseenAchievementState(): void { + const unseenAchievements = this._readUnseenAchievements(); + if (!this._haveSameAchievements(this._unseenAchievements.get(), unseenAchievements)) { + this._unseenAchievements.set(unseenAchievements, undefined); + } + } + + private _haveSameAchievements(first: readonly ChatPetAchievementId[], second: readonly ChatPetAchievementId[]): boolean { + return first.length === second.length && first.every((id, index) => id === second[index]); + } + + private _refreshSelectedAccessory(): void { + this._selectedAccessory.set(this._readSelectedAccessory(), undefined); + } + + private _readSelectedAccessory(): ChatPetAccessoryId | undefined { + const storedAccessory = this.storageService.get(CHAT_PET_ACCESSORY_STORAGE_KEY, StorageScope.APPLICATION_SHARED) + ?? this.storageService.get(CHAT_PET_ACCESSORY_STORAGE_KEY, StorageScope.APPLICATION); + if (storedAccessory === undefined) { + this.lastInvalidStoredAccessory = undefined; + return undefined; + } + if (isChatPetAccessoryId(storedAccessory)) { + const achievement = getChatPetAchievementForAccessory(storedAccessory); + if (this._unlockedAchievements.get().includes(achievement.id)) { + this.lastInvalidStoredAccessory = undefined; + return storedAccessory; + } + } + if (this.lastInvalidStoredAccessory !== storedAccessory) { + this.lastInvalidStoredAccessory = storedAccessory; + this.logService.warn(`[ChatPetService] Ignoring unknown or locked stored accessory: ${storedAccessory}`); + } + return undefined; + } } diff --git a/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css b/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css new file mode 100644 index 00000000000000..217cb1d5579bef --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css @@ -0,0 +1,235 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-pet-achievements-editor, +.chat-pet-achievements-widget { + width: 100%; + height: 100%; + overflow: hidden; +} + +.chat-pet-achievements-content { + box-sizing: border-box; + min-width: 0; + padding: var(--vscode-spacing-size240); +} + +.chat-pet-achievements-inner { + max-width: 900px; + margin: 0 auto; +} + +.chat-pet-achievements-inner h1, +.chat-pet-achievements-inner h2, +.chat-pet-achievements-inner h3, +.chat-pet-achievements-inner p { + margin-top: 0; +} + +.chat-pet-achievements-inner h1 { + margin-bottom: var(--vscode-spacing-size80); + font-size: var(--vscode-fontSize-heading2); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.chat-pet-achievements-intro { + margin-bottom: var(--vscode-spacing-size240); + color: var(--vscode-descriptionForeground); +} + +.chat-pet-achievements-collection-header h2 { + margin-bottom: var(--vscode-spacing-size120); + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.chat-pet-achievements-collection-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--vscode-spacing-size120); +} + +.chat-pet-achievements-count { + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +.chat-pet-achievements-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--vscode-spacing-size160); + margin: 0; + padding: 0; + list-style: none; +} + +.chat-pet-achievements-widget.narrow .chat-pet-achievements-content { + padding: var(--vscode-spacing-size160) var(--vscode-spacing-size120); +} + +.chat-pet-achievements-widget.narrow .chat-pet-achievements-list { + grid-template-columns: minmax(0, 1fr); +} + +.chat-pet-achievement-card.monaco-button { + box-sizing: border-box; + display: block; + position: relative; + height: 100%; + min-width: 0; + 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); + color: var(--vscode-foreground); + line-height: normal; + text-align: left; + white-space: normal; +} + +.chat-pet-achievement-card.chat-pet-achievement-roadmap { + box-sizing: border-box; + height: 100%; + min-width: 0; + padding: var(--vscode-spacing-size160); + border: var(--vscode-strokeThickness) dashed var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-medium); + background: var(--vscode-editorWidget-background); + color: var(--vscode-foreground); +} + +.chat-pet-achievement-roadmap-preview { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 96px; + height: 96px; + margin: 0 auto var(--vscode-spacing-size120); + border: var(--vscode-strokeThickness) dashed var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-medium); + background: var(--vscode-editor-background); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-heading2); + font-weight: var(--vscode-fontWeight-semiBold); + letter-spacing: 0.08em; +} + +.chat-pet-achievement-roadmap-list { + margin: 0; + padding-left: var(--vscode-spacing-size200); + color: var(--vscode-descriptionForeground); +} + +.chat-pet-achievement-roadmap-list li + li { + margin-top: var(--vscode-spacing-size40); +} + +.chat-pet-achievements-experimental { + margin: var(--vscode-spacing-size200) 0 0; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body2); +} + +.chat-pet-achievement-new-badge { + position: absolute; + z-index: 1; + top: var(--vscode-spacing-size100); + right: var(--vscode-spacing-size100); + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size60); + border: var(--vscode-strokeThickness) solid var(--vscode-badge-background); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: var(--vscode-fontSize-label3); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: 1; +} + +.chat-pet-achievement-new-badge.hidden { + display: none; +} + +.chat-pet-achievement-card.monaco-button:not(.locked):hover { + border-color: var(--vscode-focusBorder); + background: var(--vscode-list-hoverBackground); +} + +.chat-pet-achievement-card.monaco-button:focus { + outline: none; +} + +.chat-pet-achievement-card.monaco-button:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.chat-pet-achievement-card.monaco-button.wearing { + border-color: var(--vscode-focusBorder); +} + +.chat-pet-achievement-card.monaco-button.locked { + filter: grayscale(1); + opacity: 0.5; +} + +.chat-pet-achievement-previews { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--vscode-spacing-size80); + margin-bottom: var(--vscode-spacing-size120); +} + +.chat-pet-achievement-preview { + display: block; + width: 96px; + height: 96px; + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-medium); + background: var(--vscode-editor-background); + image-rendering: pixelated; +} + +.chat-pet-achievement-card-content { + min-width: 0; +} + +.chat-pet-achievement-card h3 { + margin-bottom: var(--vscode-spacing-size40); + overflow-wrap: anywhere; + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.chat-pet-achievement-state { + display: inline-block; + margin-bottom: var(--vscode-spacing-size100); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.chat-pet-achievement-description, +.chat-pet-achievement-secret, +.chat-pet-achievement-reward { + margin-bottom: var(--vscode-spacing-size80); + overflow-wrap: anywhere; +} + +.chat-pet-achievement-description, +.chat-pet-achievement-reward { + color: var(--vscode-descriptionForeground); +} + +.hc-black .chat-pet-achievement-card, +.hc-light .chat-pet-achievement-card, +.hc-black .chat-pet-achievement-preview, +.hc-light .chat-pet-achievement-preview, +.hc-black .chat-pet-achievement-roadmap-preview, +.hc-light .chat-pet-achievement-roadmap-preview { + border-color: var(--vscode-contrastBorder); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts new file mode 100644 index 00000000000000..d64df84b315889 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts @@ -0,0 +1,271 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FileAccess } from '../../../../../base/common/network.js'; +import type { IChatPetAccessory } from '../chatPetAchievements.js'; +import type { ChatPetState } from './chatPetWidget.js'; +import { ChatPetAccessoryRigPose, getChatPetAccessoryRigFrame, getChatPetAntennaeOcclusionBounds, getChatPetEyeAccessoryAnchor } from './chatPetAccessoryRig.js'; + +export const CHAT_PET_ACCESSORY_ATLAS_CELL_SIZE = 64; +export const CHAT_PET_WIDE_ACCESSORY_ATLAS_CELL_SIZE = 96; +export const CHAT_PET_ACCESSORY_ATLAS_WIDTH = CHAT_PET_ACCESSORY_ATLAS_CELL_SIZE * 4; +export const CHAT_PET_ACCESSORY_ATLAS_HEIGHT = CHAT_PET_ACCESSORY_ATLAS_CELL_SIZE * 3; + +export interface IChatPetFixedOrientationDecoration { + readonly frameBounds: readonly (readonly [number, number, number, number])[]; + readonly sourceFrame: number; +} + +export interface IChatPetAccessoryImageSource { + readonly url: string; + readonly cellSize: number; + readonly width: number; + readonly height: number; +} + +export interface IChatPetImageDimensions { + readonly naturalWidth: number; + readonly naturalHeight: number; +} + +export function getChatPetAccessoryImageSource(accessory: IChatPetAccessory): IChatPetAccessoryImageSource { + const cellSize = accessory.atlasCellSize ?? CHAT_PET_ACCESSORY_ATLAS_CELL_SIZE; + return { + url: FileAccess.asBrowserUri(`vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/${accessory.atlasName}.png`).toString(true), + cellSize, + width: cellSize * 4, + height: cellSize * 3, + }; +} + +export function hasChatPetBodyImageDimensions(image: IChatPetImageDimensions, frameWidth: number, frameHeight: number, frameCount: number): boolean { + return image.naturalWidth === frameWidth * frameCount && image.naturalHeight === frameHeight; +} + +export function hasChatPetAccessoryImageDimensions(image: IChatPetImageDimensions, source: IChatPetAccessoryImageSource): boolean { + return image.naturalWidth === source.width && image.naturalHeight === source.height; +} + +export function drawChatPetComposite( + context: CanvasRenderingContext2D, + bodyImage: HTMLImageElement, + accessoryImage: HTMLImageElement | undefined, + bodyFrameIndex: number, + rigFrameIndex: number, + frameWidth: number, + frameHeight: number, + facingDirection: 'left' | 'right', + state: ChatPetState, + fixedOrientationDecorations?: readonly IChatPetFixedOrientationDecoration[], + includeEyeAccessory = true, + eyeAccessoryMirrorsWithFacing = true, + coversAntennae = false, +): void { + context.clearRect(0, 0, frameWidth, frameHeight); + const sourceX = bodyFrameIndex * frameWidth; + const rigFrame = getChatPetAccessoryRigFrame(state, rigFrameIndex); + if (fixedOrientationDecorations !== undefined && facingDirection === 'left') { + drawMirroredChatPetLayer(context, bodyImage, sourceX, 0, frameWidth, frameHeight); + drawFixedOrientationDecorations(context, bodyImage, bodyFrameIndex, frameWidth, fixedOrientationDecorations); + if (accessoryImage) { + if (coversAntennae) { + clearChatPetAntennae(context, state, rigFrameIndex, frameWidth, true); + } + context.save(); + context.globalCompositeOperation = 'destination-over'; + drawMirroredAccessoryLayer(context, accessoryImage, rigFrame, 'back', frameWidth); + context.restore(); + drawMirroredAccessoryLayer(context, accessoryImage, rigFrame, 'front', frameWidth); + if (includeEyeAccessory) { + if (eyeAccessoryMirrorsWithFacing) { + drawMirroredEyeAccessory(context, accessoryImage, rigFrame, frameWidth); + } else { + drawEyeAccessory(context, accessoryImage, rigFrame, getChatPetEyeAccessoryAnchor(state, rigFrameIndex, 'left', false, frameWidth)); + } + } + } + return; + } + if (accessoryImage) { + drawTrackedAccessoryLayer(context, accessoryImage, rigFrame, 'back', frameWidth); + } + context.drawImage(bodyImage, sourceX, 0, frameWidth, frameHeight, 0, 0, frameWidth, frameHeight); + if (accessoryImage) { + if (coversAntennae) { + clearChatPetAntennae(context, state, rigFrameIndex, frameWidth, false); + context.save(); + context.globalCompositeOperation = 'destination-over'; + drawTrackedAccessoryLayer(context, accessoryImage, rigFrame, 'back', frameWidth); + context.restore(); + } + drawTrackedAccessoryLayer(context, accessoryImage, rigFrame, 'front', frameWidth); + if (includeEyeAccessory) { + drawEyeAccessory(context, accessoryImage, rigFrame); + } + } +} + +export function drawChatPetAccessory(context: CanvasRenderingContext2D, accessoryImage: HTMLImageElement, state: ChatPetState, frameIndex: number, facingDirection: 'left' | 'right'): void { + const rigFrame = getChatPetAccessoryRigFrame(state, frameIndex); + const mirrorsHeadAccessory = facingDirection === 'left' !== !!rigFrame.mirrorsHeadAccessory; + if (mirrorsHeadAccessory) { + drawMirroredAccessoryLayer(context, accessoryImage, rigFrame, 'back', context.canvas.width); + drawMirroredAccessoryLayer(context, accessoryImage, rigFrame, 'front', context.canvas.width); + drawMirroredEyeAccessory(context, accessoryImage, rigFrame, context.canvas.width); + return; + } + drawAccessoryLayer(context, accessoryImage, rigFrame, 'back'); + drawAccessoryLayer(context, accessoryImage, rigFrame, 'front'); + drawEyeAccessory(context, accessoryImage, rigFrame); +} + +export function drawChatPetEyeAccessory(context: CanvasRenderingContext2D, accessoryImage: HTMLImageElement, state: ChatPetState, frameIndex: number, facingDirection: 'left' | 'right', mirrorsWithFacing = true, gazeOffset?: readonly [number, number]): void { + context.clearRect(0, 0, context.canvas.width, context.canvas.height); + const rigFrame = getChatPetAccessoryRigFrame(state, frameIndex); + if (facingDirection === 'left' && mirrorsWithFacing) { + drawMirroredEyeAccessory(context, accessoryImage, rigFrame, context.canvas.width); + return; + } + const anchor = getChatPetEyeAccessoryAnchor(state, frameIndex, facingDirection, mirrorsWithFacing, context.canvas.width); + drawEyeAccessory(context, accessoryImage, rigFrame, anchor && gazeOffset ? { + x: anchor.x + gazeOffset[0], + y: anchor.y + gazeOffset[1], + } : anchor); +} + +function drawMirroredChatPetLayer(context: CanvasRenderingContext2D, image: HTMLImageElement, sourceX: number, sourceY: number, frameWidth: number, frameHeight: number): void { + context.save(); + context.translate(frameWidth, 0); + context.scale(-1, 1); + context.drawImage(image, sourceX, sourceY, frameWidth, frameHeight, 0, 0, frameWidth, frameHeight); + context.restore(); +} + +function clearChatPetAntennae(context: CanvasRenderingContext2D, state: ChatPetState, frameIndex: number, frameWidth: number, mirrored: boolean): void { + const bounds = getChatPetAntennaeOcclusionBounds(state, frameIndex); + if (!bounds) { + return; + } + const x = mirrored ? frameWidth - bounds.x - bounds.width : bounds.x; + context.clearRect(x, bounds.y, bounds.width, bounds.height); +} + +function drawFixedOrientationDecorations(context: CanvasRenderingContext2D, bodyImage: HTMLImageElement, frameIndex: number, frameWidth: number, decorations: readonly IChatPetFixedOrientationDecoration[]): void { + for (const decoration of decorations) { + const currentBounds = decoration.frameBounds[frameIndex]; + const canonicalBounds = decoration.frameBounds[decoration.sourceFrame]; + const [currentLeft, currentTop, currentRight, currentBottom] = currentBounds; + const [canonicalLeft, canonicalTop, canonicalRight, canonicalBottom] = canonicalBounds; + const canonicalWidth = canonicalRight - canonicalLeft; + const canonicalHeight = canonicalBottom - canonicalTop; + context.clearRect(frameWidth - currentRight, currentTop, currentRight - currentLeft, currentBottom - currentTop); + context.drawImage( + bodyImage, + decoration.sourceFrame * frameWidth + canonicalLeft, + canonicalTop, + canonicalWidth, + canonicalHeight, + frameWidth - currentLeft - canonicalWidth, + currentTop, + canonicalWidth, + canonicalHeight + ); + } +} + +type ChatPetAccessoryLayer = 'back' | 'front'; + +const rigPoseColumn: Record<ChatPetAccessoryRigPose, number> = { + upright: 0, + sleeping: 1, + airborne: 0, + impact: 2, + splat: 3, +}; + +const compactHeadPivot: Record<ChatPetAccessoryRigPose, readonly [number, number]> = { + upright: [32, 48], + sleeping: [32, 48], + airborne: [32, 48], + impact: [32, 32], + splat: [32, 48], +}; + +const wideHeadPivot: Record<ChatPetAccessoryRigPose, readonly [number, number]> = { + upright: [48, 40], + sleeping: [48, 40], + airborne: [48, 40], + impact: [48, 40], + splat: [48, 80], +}; + +function getAtlasCellSize(atlasImage: HTMLImageElement): number { + return atlasImage.naturalWidth / 4; +} + +function drawAccessoryLayer(context: CanvasRenderingContext2D, atlasImage: HTMLImageElement, rigFrame: ReturnType<typeof getChatPetAccessoryRigFrame>, layer: ChatPetAccessoryLayer): void { + const cellSize = getAtlasCellSize(atlasImage); + const sourceX = rigPoseColumn[rigFrame.pose] * cellSize; + if (rigFrame.head) { + const headSourceY = (layer === 'back' ? 0 : 1) * cellSize; + const [headPivotX, headPivotY] = cellSize === CHAT_PET_WIDE_ACCESSORY_ATLAS_CELL_SIZE + ? wideHeadPivot[rigFrame.pose] + : compactHeadPivot[rigFrame.pose]; + context.drawImage( + atlasImage, + sourceX, + headSourceY, + cellSize, + cellSize, + rigFrame.head.x - headPivotX, + rigFrame.head.y - headPivotY, + cellSize, + cellSize + ); + } +} + +function drawTrackedAccessoryLayer(context: CanvasRenderingContext2D, atlasImage: HTMLImageElement, rigFrame: ReturnType<typeof getChatPetAccessoryRigFrame>, layer: ChatPetAccessoryLayer, frameWidth: number): void { + if (rigFrame.mirrorsHeadAccessory) { + drawMirroredAccessoryLayer(context, atlasImage, rigFrame, layer, frameWidth); + } else { + drawAccessoryLayer(context, atlasImage, rigFrame, layer); + } +} + +function drawMirroredAccessoryLayer(context: CanvasRenderingContext2D, atlasImage: HTMLImageElement, rigFrame: ReturnType<typeof getChatPetAccessoryRigFrame>, layer: ChatPetAccessoryLayer, frameWidth: number): void { + context.save(); + context.translate(frameWidth, 0); + context.scale(-1, 1); + drawAccessoryLayer(context, atlasImage, rigFrame, layer); + context.restore(); +} + +function drawEyeAccessory(context: CanvasRenderingContext2D, atlasImage: HTMLImageElement, rigFrame: ReturnType<typeof getChatPetAccessoryRigFrame>, eyeAnchor = rigFrame.rightEye): void { + if (!eyeAnchor) { + return; + } + const cellSize = getAtlasCellSize(atlasImage); + const sourceX = rigPoseColumn[rigFrame.pose] * cellSize; + context.drawImage( + atlasImage, + sourceX, + 2 * cellSize, + cellSize, + cellSize, + eyeAnchor.x, + eyeAnchor.y, + cellSize, + cellSize + ); +} + +function drawMirroredEyeAccessory(context: CanvasRenderingContext2D, atlasImage: HTMLImageElement, rigFrame: ReturnType<typeof getChatPetAccessoryRigFrame>, frameWidth: number): void { + context.save(); + context.translate(frameWidth, 0); + context.scale(-1, 1); + drawEyeAccessory(context, atlasImage, rigFrame); + context.restore(); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRig.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRig.ts new file mode 100644 index 00000000000000..553d3671c401ad --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRig.ts @@ -0,0 +1,232 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ChatPetState } from './chatPetWidget.js'; + +export type ChatPetAccessoryRigPose = 'upright' | 'sleeping' | 'airborne' | 'impact' | 'splat'; +export type ChatPetAccessoryTrack = 'idle' | 'sleep' | 'waking' | 'typing' | 'rendering' | 'buttonPress' | 'love' | 'clapping' | 'jump' | 'cool' | 'yapping' | 'sing' | 'speechless' | 'worry' | 'dizzy' | 'falling' | 'wallImpact' | 'splat' | 'search'; + +export interface IChatPetAccessoryAnchor { + readonly x: number; + readonly y: number; +} + +export interface IChatPetAccessoryRigFrame { + readonly pose: ChatPetAccessoryRigPose; + readonly head?: IChatPetAccessoryAnchor; + readonly rightEye?: IChatPetAccessoryAnchor; + readonly mirrorsHeadAccessory?: boolean; +} + +export interface IChatPetAntennaeOcclusionBounds { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +interface IChatPetAccessoryTrackSpan { + readonly firstFrame: number; + readonly lastFrame: number; + readonly head: IChatPetAccessoryAnchor; + readonly pose?: ChatPetAccessoryRigPose; + readonly rightEye?: IChatPetAccessoryAnchor; + readonly hideEyeAccessory?: boolean; + readonly mirrorsHeadAccessory?: boolean; +} + +const defaultHeadAnchor: IChatPetAccessoryAnchor = { x: 48, y: 32 }; +const defaultRightEyeAnchor: IChatPetAccessoryAnchor = { x: 56, y: 56 }; +export const CHAT_PET_HEAD_WEAR_OFFSET = 8; + +const trackSpans: Partial<Record<ChatPetAccessoryTrack, readonly IChatPetAccessoryTrackSpan[]>> = { + idle: [ + { firstFrame: 0, lastFrame: 19, head: defaultHeadAnchor, rightEye: defaultRightEyeAnchor }, + { firstFrame: 20, lastFrame: 49, head: { x: 48, y: 36 }, rightEye: { x: 56, y: 60 } }, + ], + rendering: [ + { firstFrame: 0, lastFrame: 19, head: defaultHeadAnchor, rightEye: defaultRightEyeAnchor }, + { firstFrame: 20, lastFrame: 49, head: { x: 48, y: 36 }, rightEye: { x: 56, y: 60 } }, + ], + sleep: [ + { firstFrame: 0, lastFrame: 2, head: defaultHeadAnchor, rightEye: { x: 56, y: 64 } }, + { firstFrame: 3, lastFrame: 7, head: { x: 48, y: 36 }, rightEye: { x: 56, y: 64 } }, + ], + waking: [ + { firstFrame: 0, lastFrame: 2, head: { x: 48, y: 36 }, pose: 'sleeping', rightEye: { x: 56, y: 64 } }, + { firstFrame: 3, lastFrame: 7, head: defaultHeadAnchor, pose: 'upright', rightEye: defaultRightEyeAnchor }, + ], + love: [ + { firstFrame: 0, lastFrame: 3, head: { x: 48, y: 36 } }, + { firstFrame: 4, lastFrame: 5, head: defaultHeadAnchor }, + ], + jump: [ + { firstFrame: 0, lastFrame: 0, head: defaultHeadAnchor, rightEye: { x: 56, y: 56 } }, + { firstFrame: 1, lastFrame: 1, head: { x: 48, y: 48 }, rightEye: { x: 56, y: 64 } }, + { firstFrame: 2, lastFrame: 2, head: defaultHeadAnchor, rightEye: { x: 56, y: 48 } }, + { firstFrame: 3, lastFrame: 3, head: defaultHeadAnchor, rightEye: { x: 56, y: 40 } }, + { firstFrame: 4, lastFrame: 4, head: { x: 48, y: 56 }, rightEye: { x: 56, y: 64 } }, + { firstFrame: 5, lastFrame: 5, head: defaultHeadAnchor, rightEye: { x: 56, y: 56 } }, + ], + cool: [ + { firstFrame: 0, lastFrame: 1, head: defaultHeadAnchor }, + { firstFrame: 2, lastFrame: 2, head: { x: 48, y: 36 } }, + { firstFrame: 3, lastFrame: 8, head: defaultHeadAnchor }, + ], + sing: [ + { firstFrame: 0, lastFrame: 3, head: { x: 48, y: 52 }, rightEye: { x: 56, y: 72 } }, + ], + worry: [ + { firstFrame: 0, lastFrame: 0, head: defaultHeadAnchor }, + { firstFrame: 1, lastFrame: 1, head: defaultHeadAnchor, mirrorsHeadAccessory: true }, + ], + dizzy: [ + { firstFrame: 0, lastFrame: 7, head: { x: 48, y: 48 }, hideEyeAccessory: true }, + ], + falling: [ + { firstFrame: 0, lastFrame: 5, head: defaultHeadAnchor, rightEye: { x: 48, y: 48 } }, + ], + splat: [ + { firstFrame: 0, lastFrame: 0, head: { x: 48, y: 72 }, pose: 'splat', hideEyeAccessory: true }, + { firstFrame: 1, lastFrame: 1, head: { x: 48, y: 64 }, pose: 'splat', hideEyeAccessory: true }, + { firstFrame: 2, lastFrame: 2, head: { x: 48, y: 48 }, pose: 'splat', hideEyeAccessory: true }, + { firstFrame: 3, lastFrame: 3, head: defaultHeadAnchor, pose: 'upright' }, + ], +}; + +export function getChatPetAccessoryTrack(state: ChatPetState): ChatPetAccessoryTrack { + switch (state) { + case 'achievementUnlocked': + return 'rendering'; + case 'sleep': + case 'waking': + case 'typing': + case 'rendering': + case 'love': + case 'clapping': + case 'jump': + case 'cool': + case 'sing': + case 'speechless': + case 'worry': + case 'dizzy': + case 'falling': + case 'splat': + return state; + case 'buttonPress': + return 'buttonPress'; + case 'yappingMouthOpen': + return 'yapping'; + case 'wallImpact': + return 'wallImpact'; + case 'onTheRun': + case 'searching': + case 'searchingDown': + return 'search'; + case 'complete': + case 'yapping': + case 'idle': + return 'idle'; + } +} + +function getDefaultChatPetAccessoryRigPose(state: ChatPetState): ChatPetAccessoryRigPose { + switch (state) { + case 'sleep': + case 'waking': + return 'sleeping'; + case 'jump': + case 'dizzy': + case 'falling': + return 'airborne'; + case 'wallImpact': + return 'impact'; + case 'splat': + return 'splat'; + default: + return 'upright'; + } +} + +export function getChatPetAccessoryRigPose(state: ChatPetState, frameIndex = 0): ChatPetAccessoryRigPose { + const spans = trackSpans[getChatPetAccessoryTrack(state)]; + const span = spans?.find(candidate => frameIndex >= candidate.firstFrame && frameIndex <= candidate.lastFrame); + return span?.pose ?? getDefaultChatPetAccessoryRigPose(state); +} + +export function getChatPetAccessoryRigFrame(state: ChatPetState, frameIndex: number): IChatPetAccessoryRigFrame { + const spans = trackSpans[getChatPetAccessoryTrack(state)]; + const span = spans?.find(candidate => frameIndex >= candidate.firstFrame && frameIndex <= candidate.lastFrame); + const trackedHead = span?.head ?? defaultHeadAnchor; + const hideEyeAccessory = span?.hideEyeAccessory || doesChatPetStateHideEyeAccessory(state); + return { + pose: span?.pose ?? getDefaultChatPetAccessoryRigPose(state), + head: state === 'love' || state === 'complete' || state === 'dizzy' ? undefined : { + x: trackedHead.x, + y: trackedHead.y + CHAT_PET_HEAD_WEAR_OFFSET, + }, + rightEye: hideEyeAccessory ? undefined : span?.rightEye ?? defaultRightEyeAnchor, + ...(span?.mirrorsHeadAccessory ? { mirrorsHeadAccessory: true } : {}), + }; +} + +export function getChatPetAntennaeOcclusionBounds(state: ChatPetState, frameIndex: number): IChatPetAntennaeOcclusionBounds | undefined { + const rigFrame = getChatPetAccessoryRigFrame(state, frameIndex); + if (!rigFrame.head) { + return undefined; + } + if (rigFrame.pose === 'impact') { + return { + x: 16, + y: 24, + width: 64, + height: 8, + }; + } + return { + x: rigFrame.head.x - 32, + y: rigFrame.head.y - 48, + width: 64, + height: 40, + }; +} + +export function getChatPetEyeAccessoryAnchor(state: ChatPetState, frameIndex: number, facingDirection: 'left' | 'right', mirrorsWithFacing: boolean, frameWidth = 96): IChatPetAccessoryAnchor | undefined { + const anchor = getChatPetAccessoryRigFrame(state, frameIndex).rightEye; + if (!anchor || facingDirection === 'right' || mirrorsWithFacing) { + return anchor; + } + return { + x: anchor.x - 16 + frameWidth - 96, + y: anchor.y, + }; +} + +export function getChatPetReducedMotionRigFrame(state: ChatPetState): number { + switch (state) { + case 'sleep': + return 4; + case 'waking': + return 7; + case 'buttonPress': + return 4; + case 'love': + return 5; + case 'splat': + return 3; + default: + return 0; + } +} + +function doesChatPetStateHideEyeAccessory(state: ChatPetState): boolean { + return state === 'love' + || state === 'complete' + || state === 'cool' + || state === 'speechless' + || state === 'worry' + || state === 'dizzy' + || state === 'wallImpact'; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts index bc20099291d8f2..db852c03195efb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts @@ -18,16 +18,22 @@ import { FileAccess } from '../../../../../base/common/network.js'; import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { localize } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; 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 { ChatPetVariant, IChatPetService } from '../chatPetService.js'; +import { drawChatPetComposite, drawChatPetEyeAccessory, getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions, IChatPetAccessoryImageSource, IChatPetFixedOrientationDecoration } from './chatPetAccessoryRenderer.js'; +import { getChatPetAccessoryRigFrame, getChatPetReducedMotionRigFrame } from './chatPetAccessoryRig.js'; -export type ChatPetState = 'idle' | 'sleep' | 'waking' | 'typing' | 'rendering' | 'buttonPress' | 'complete' | 'love' | 'clapping' | 'jump' | 'cool' | 'yapping' | 'yappingMouthOpen' | 'sing' | 'speechless' | 'worry' | 'dizzy' | 'falling' | 'wallImpact' | 'splat' | 'onTheRun' | 'searching' | 'searchingDown'; +export type ChatPetState = 'idle' | 'sleep' | 'waking' | 'typing' | 'rendering' | 'achievementUnlocked' | 'buttonPress' | 'complete' | 'love' | 'clapping' | 'jump' | 'cool' | 'yapping' | 'yappingMouthOpen' | 'sing' | 'speechless' | 'worry' | 'dizzy' | 'falling' | 'wallImpact' | 'splat' | 'onTheRun' | 'searching' | 'searchingDown'; export type ChatPetClickInteraction = Extract<ChatPetState, 'buttonPress' | 'complete' | 'love' | 'cool' | 'yapping' | 'sing' | 'speechless' | 'worry'>; export const CHAT_PET_IDLE_SLEEP_DELAY = 20_000; export const CHAT_PET_CONFIRMATION_ATTENTION_DURATION = 2_000; +export const CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION = 10_000; export const CHAT_PET_ICON_TRANSFORMATION_CHANCE = 1 / 100; export const CHAT_PET_YAPPING_CHANCE = 1 / 100; export const CHAT_PET_WALL_IMPACT_DURATION = 48; @@ -106,18 +112,14 @@ const WORRY_FRAME_DURATIONS = [600, 600]; const DIZZY_FRAME_DURATIONS = Array.from({ length: 8 }, () => 120); const SEARCH_FRAME_DURATIONS = [500, 500, 500, 500]; -interface ChatPetFixedOrientationDecoration { - readonly frameBounds: readonly (readonly [number, number, number, number])[]; - readonly sourceFrame: number; -} - interface ChatPetSpriteSource { readonly url: string; readonly frameWidth: number; readonly frameHeight?: number; - readonly fixedOrientationDecorations?: readonly ChatPetFixedOrientationDecoration[]; + readonly fixedOrientationDecorations?: readonly IChatPetFixedOrientationDecoration[]; readonly frameDurations: readonly number[]; readonly iterations: number; + readonly accessoryRigFrame?: number; } interface ChatPetSpriteSources { @@ -128,10 +130,32 @@ interface ChatPetSpriteSources { interface ChatPetSpriteElement { readonly container: HTMLElement; readonly image: HTMLImageElement; + readonly accessoryImages?: readonly HTMLImageElement[]; readonly canvas: HTMLCanvasElement; + activeAccessory?: ChatPetAccessoryId; + activeAccessoryImage?: HTMLImageElement; +} + +interface ChatPetPendingRender { + readonly generation: number; + readonly sprite: ChatPetSpriteElement; + readonly bodySource: ChatPetSpriteSource; + readonly accessorySource: IChatPetAccessoryImageSource | undefined; + readonly accessoryImage: HTMLImageElement | undefined; + readonly accessory: ChatPetAccessoryId | undefined; + readonly state: ChatPetState; + readonly useStaticSprite: boolean; +} + +interface ChatPetPendingAccessorySwitch { + readonly generation: number; + readonly sprite: ChatPetSpriteElement; + readonly source: IChatPetAccessoryImageSource; + readonly image: HTMLImageElement; + readonly accessory: ChatPetAccessoryId; } -const CHAT_PET_SING_FIXED_ORIENTATION_DECORATIONS: readonly ChatPetFixedOrientationDecoration[] = [ +export const CHAT_PET_SING_FIXED_ORIENTATION_DECORATIONS: readonly IChatPetFixedOrientationDecoration[] = [ { frameBounds: [ [16, 36, 80, 52], @@ -240,6 +264,7 @@ export function getChatPetSpriteName(state: ChatPetState, quality: string | unde case 'typing': return `buddy-typing-${variant}`; case 'rendering': + case 'achievementUnlocked': return `buddy-rendering-${variant}`; case 'yappingMouthOpen': return `buddy-yapping-${variant}`; @@ -269,6 +294,7 @@ export function getChatPetFrameDurations(state: ChatPetState): readonly number[] case 'splat': return SPLAT_FRAME_DURATIONS; case 'rendering': + case 'achievementUnlocked': return IDLE_FRAME_DURATIONS; case 'clapping': return CLAPPING_FRAME_DURATIONS; @@ -298,7 +324,7 @@ export function getChatPetFrameDurations(state: ChatPetState): readonly number[] } } -function createSpriteSources(name: string, state: ChatPetState, tracksCursor = true, sourceWidth?: number, sourceHeight = CHAT_PET_SOURCE_SIZE, fixedOrientationDecorations?: readonly ChatPetFixedOrientationDecoration[]): ChatPetSpriteSources { +function createSpriteSources(name: string, state: ChatPetState, tracksCursor = true, sourceWidth?: number, sourceHeight = CHAT_PET_SOURCE_SIZE, fixedOrientationDecorations?: readonly IChatPetFixedOrientationDecoration[]): ChatPetSpriteSources { const root = 'vs/workbench/contrib/chat/browser/widget/media/chatPet'; const suffix = tracksCursor ? '-tracking-96' : `-${sourceHeight}`; const frameDurations = getChatPetFrameDurations(state); @@ -314,6 +340,7 @@ function createSpriteSources(name: string, state: ChatPetState, tracksCursor = t fixedOrientationDecorations, frameDurations: [], iterations: 1, + accessoryRigFrame: getChatPetReducedMotionRigFrame(state), }; return { animated: frameDurations.length === 0 ? staticSource : { @@ -346,6 +373,7 @@ function getSpriteSources(variant: ChatPetVariant): Record<ChatPetState, ChatPet waking: createSpriteSources(getChatPetSpriteName('waking', variant), 'waking', false, CHAT_PET_SLEEP_SOURCE_WIDTH), typing: createStateSpriteSources('typing'), rendering: createStateSpriteSources('rendering'), + achievementUnlocked: createStateSpriteSources('achievementUnlocked'), buttonPress: createStateSpriteSources('buttonPress'), complete: createStateSpriteSources('complete'), love: createStateSpriteSources('love'), @@ -420,7 +448,21 @@ function getRespawnSpriteSources(variant: ChatPetVariant): ChatPetSpriteSources } function doesChatPetStateSpeak(state: ChatPetState | undefined): boolean { - return state === 'rendering'; + 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)'; + context.fillRect(56, 34, 24, 24); + context.fillStyle = 'rgb(255, 205, 15)'; + const rows = ['..#..', '.###.', '#####', '.#.#.', '#...#']; + for (let y = 0; y < rows.length; y++) { + for (let x = 0; x < rows[y].length; x++) { + if (rows[y][x] === '#') { + context.fillRect(58 + x * 4, 36 + y * 4, 4, 4); + } + } + } } export function isChatPetImageSource(image: Pick<HTMLImageElement, 'getAttribute'>, source: string): boolean { @@ -517,6 +559,8 @@ function getTransientStateDuration(state: ChatPetState): number { return WORRY_STATE_DURATION; case 'dizzy': return DIZZY_STATE_DURATION; + case 'achievementUnlocked': + return CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION; case 'waking': return WAKE_STATE_DURATION; default: @@ -558,6 +602,10 @@ export function getChatPetGazeDirection(cursorX: number, cursorY: number, petCen ]; } +export function getChatPetEyeAccessoryGazeOffset(gazeDirection: readonly [number, number]): readonly [number, number] { + return [gazeDirection[0] * 4, gazeDirection[1] * 4]; +} + export class ChatPetBlinkController extends Disposable { private readonly _blinkScheduler = this._register(new RunOnceScheduler(() => this._startBlink(), 0)); @@ -886,6 +934,13 @@ export function getChatPetWideSpriteHorizontalOffset(state: ChatPetState | undef : Math.min(0, (inputRight - buttonRight) / scale - overhang); } +export function setChatPetWideLayerOffset(offset: number, layers: readonly HTMLElement[]): void { + const translate = offset === 0 ? '' : `${offset}px`; + for (const layer of layers) { + layer.style.translate = translate; + } +} + export class ChatPetHopController extends Disposable { private readonly _stepScheduler = this._register(new RunOnceScheduler(() => this._applyStep(), HOP_APEX_DELAY)); @@ -961,7 +1016,15 @@ export class ChatPetWidget extends Disposable { private readonly _respawnEffect: ChatPetSpriteElement; private readonly _sprites: readonly ChatPetSpriteElement[]; private readonly _speechBubble: ChatPetSpriteElement; + private _speechBubbleState: 'rendering' | 'achievementUnlocked' | undefined; private readonly _eyes: HTMLElement; + private readonly _eyeAccessoryContainer: HTMLElement; + private readonly _eyeAccessory: HTMLCanvasElement; + private _eyeAccessoryVisible = false; + private _eyeAccessoryFixedOrientation = false; + private _eyeAccessoryDimensions: { readonly frameWidth: number; readonly frameHeight: number } | undefined; + private _eyeAccessoryGazeOffset: readonly [number, number] = [0, 0]; + private _redrawEyeAccessory: (() => void) | undefined; private readonly _pupils: HTMLElement[] = []; private readonly _blinkController: ChatPetBlinkController; private readonly _facingController = new ChatPetFacingController(); @@ -1000,9 +1063,14 @@ export class ChatPetWidget extends Disposable { private readonly _contextMenuActions = this._register(new MutableDisposable<DisposableStore>()); private _cursorPosition: readonly [number, number] | undefined; private _activeSprite: ChatPetSpriteElement | undefined; - private _pendingSprite: ChatPetSpriteElement | undefined; - private _pendingSource: ChatPetSpriteSource | undefined; - private _pendingState: ChatPetState | undefined; + private _activeSource: ChatPetSpriteSource | undefined; + private _pendingRender: ChatPetPendingRender | undefined; + private _pendingAccessorySwitch: ChatPetPendingAccessorySwitch | undefined; + private _renderGeneration = 0; + private _accessoryGeneration = 0; + private _activeFrameIndex = 0; + private _redrawActiveFrame: (() => void) | undefined; + private readonly _failedAccessorySources = new Set<string>(); private _renderedState: ChatPetState | undefined; private _motionReduced = false; private _enabled = false; @@ -1022,6 +1090,7 @@ export class ChatPetWidget extends Disposable { private _platformTopProvider: (() => number | undefined) | undefined; private readonly _resizeObserver: dom.DisposableResizeObserver; private _variant: ChatPetVariant; + private _selectedAccessory: ChatPetAccessoryId | undefined; private _scale = 1; constructor( @@ -1035,18 +1104,21 @@ export class ChatPetWidget extends Disposable { @IChatPetService private readonly chatPetService: IChatPetService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, @IContextMenuService private readonly contextMenuService: IContextMenuService, + @ICommandService private readonly commandService: ICommandService, + @ILogService private readonly logService: ILogService, @IHostService private readonly hostService: IHostService, ) { super(); this._variant = this.chatPetService.variant.get(); + this._selectedAccessory = this.chatPetService.selectedAccessory.get(); this._searchScheduler = this._register(new RunOnceScheduler(() => this._trySearch(), SEARCH_INTERVAL)); this.parent.classList.add('chat-pet-host'); this._overlay = dom.$('.chat-pet-overlay'); this.parent.prepend(this._overlay); this._register(toDisposable(() => this._overlay.remove())); this._button = this._register(new Button(this._overlay, { - ariaLabel: this._getAriaLabel(false), + ariaLabel: this._getAriaLabel(false, false), })); this._button.element.classList.add('chat-pet-button'); this._button.element.dataset.facing = this._facingController.direction; @@ -1060,6 +1132,53 @@ export class ChatPetWidget extends Disposable { respawnEffectImage.setAttribute('aria-hidden', 'true'); this._respawnEffect = { container: respawnEffectCanvas, image: respawnEffectImage, canvas: respawnEffectCanvas }; this._register(dom.addDisposableListener(respawnEffectImage, 'load', () => this._startRespawnEffectAnimation())); + this._sprites = [0, 1].map(() => { + const container = dom.append(this._visual, dom.$('.chat-pet-sprite.hidden')); + const canvas = dom.append(container, dom.$('canvas.chat-pet-canvas')) as HTMLCanvasElement; + canvas.width = CHAT_PET_SOURCE_SIZE; + canvas.height = CHAT_PET_SOURCE_SIZE; + canvas.setAttribute('aria-hidden', 'true'); + const image = dom.append(container, dom.$('img.chat-pet-spritesheet')) as HTMLImageElement; + image.alt = ''; + image.setAttribute('aria-hidden', 'true'); + const accessoryImages = [0, 1].map(() => { + const accessoryImage = dom.append(container, dom.$('img.chat-pet-spritesheet')) as HTMLImageElement; + accessoryImage.alt = ''; + accessoryImage.setAttribute('aria-hidden', 'true'); + return accessoryImage; + }); + const sprite: ChatPetSpriteElement = { container, image, accessoryImages, canvas }; + this._register(dom.addDisposableListener(image, 'load', () => this._onBodyImageLoad(sprite))); + this._register(dom.addDisposableListener(image, 'error', () => this._onBodyImageError(sprite))); + for (const accessoryImage of accessoryImages) { + this._register(dom.addDisposableListener(accessoryImage, 'load', () => this._onAccessoryImageLoad(sprite, accessoryImage))); + this._register(dom.addDisposableListener(accessoryImage, 'error', () => this._onAccessoryImageError(sprite, accessoryImage))); + } + return sprite; + }); + this._eyes = dom.append(this._visual, dom.$('.chat-pet-eyes')); + this._eyes.setAttribute('aria-hidden', 'true'); + for (const side of ['left', 'right']) { + const eye = dom.append(this._eyes, dom.$(`.chat-pet-eye.${side}`)); + this._pupils.push(dom.append(eye, dom.$('.chat-pet-pupil'))); + } + this._blinkController = this._register(new ChatPetBlinkController(blinking => this._eyes.classList.toggle('blink', blinking))); + const targetDocument = dom.getWindow(this._button.element).document; + this._register(dom.addDisposableListener(targetDocument, 'visibilitychange', () => this._updateEyes(this._renderedState))); + this._eyeAccessoryContainer = dom.append(this._visual, dom.$('.chat-pet-eye-accessory.hidden')); + this._eyeAccessory = dom.append(this._eyeAccessoryContainer, dom.$('canvas.chat-pet-eye-accessory-canvas')) as HTMLCanvasElement; + this._eyeAccessory.width = CHAT_PET_SOURCE_SIZE; + this._eyeAccessory.height = CHAT_PET_SOURCE_SIZE; + this._eyeAccessory.setAttribute('aria-hidden', 'true'); + const speechBubbleContainer = dom.append(this._visual, dom.$('.chat-pet-speech-bubble.hidden')); + const speechBubbleCanvas = dom.append(speechBubbleContainer, dom.$('canvas.chat-pet-canvas.chat-pet-speech-canvas')) as HTMLCanvasElement; + speechBubbleCanvas.width = CHAT_PET_SOURCE_SIZE; + speechBubbleCanvas.height = CHAT_PET_SOURCE_SIZE; + speechBubbleCanvas.setAttribute('aria-hidden', 'true'); + 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._resizeObserver = this._register(new dom.DisposableResizeObserver('ChatPetWidget.dragBounds', () => { if (!this._enabled || this._getHorizontalBounds() === undefined) { return; @@ -1101,37 +1220,6 @@ export class ChatPetWidget extends Disposable { this._restoreHorizontalPosition(); this._updateSpeechBubblePosition(); } - this._sprites = [0, 1].map(() => { - const container = dom.append(this._visual, dom.$('.chat-pet-sprite.hidden')); - const canvas = dom.append(container, dom.$('canvas.chat-pet-canvas')) as HTMLCanvasElement; - canvas.width = CHAT_PET_SOURCE_SIZE; - canvas.height = CHAT_PET_SOURCE_SIZE; - canvas.setAttribute('aria-hidden', 'true'); - const image = dom.append(container, dom.$('img.chat-pet-spritesheet')) as HTMLImageElement; - image.alt = ''; - image.setAttribute('aria-hidden', 'true'); - const sprite = { container, image, canvas }; - this._register(dom.addDisposableListener(image, 'load', () => this._onImageLoad(sprite))); - return sprite; - }); - this._eyes = dom.append(this._visual, dom.$('.chat-pet-eyes')); - this._eyes.setAttribute('aria-hidden', 'true'); - for (const side of ['left', 'right']) { - const eye = dom.append(this._eyes, dom.$(`.chat-pet-eye.${side}`)); - this._pupils.push(dom.append(eye, dom.$('.chat-pet-pupil'))); - } - this._blinkController = this._register(new ChatPetBlinkController(blinking => this._eyes.classList.toggle('blink', blinking))); - const targetDocument = dom.getWindow(this._button.element).document; - this._register(dom.addDisposableListener(targetDocument, 'visibilitychange', () => this._updateEyes(this._renderedState))); - const speechBubbleContainer = dom.append(this._visual, dom.$('.chat-pet-speech-bubble.hidden')); - const speechBubbleCanvas = dom.append(speechBubbleContainer, dom.$('canvas.chat-pet-canvas.chat-pet-speech-canvas')) as HTMLCanvasElement; - speechBubbleCanvas.width = CHAT_PET_SOURCE_SIZE; - speechBubbleCanvas.height = CHAT_PET_SOURCE_SIZE; - speechBubbleCanvas.setAttribute('aria-hidden', 'true'); - 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._register(dom.addDisposableListener(speechBubbleImage, 'load', () => this._updateSpeechBubble(this._renderedState, true))); this._gazeScheduler = this._register(new dom.AnimationFrameScheduler(this._button.element, () => this._updateGaze())); this._register(dom.addDisposableListener(dom.getWindow(this._button.element).document, dom.EventType.POINTER_MOVE, (event: PointerEvent) => { @@ -1188,6 +1276,12 @@ export class ChatPetWidget extends Disposable { this._clickSuppressionScheduler.cancel(); return; } + if (this._transientState.get() === 'achievementUnlocked') { + this._transientScheduler.cancel(); + this._transientState.set(undefined, undefined); + void this.commandService.executeCommand(CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID); + return; + } if (this.chatPetService.onTheRun.get()) { this._transientState.set(undefined, undefined); this.chatPetService.setOnTheRun(false); @@ -1231,6 +1325,13 @@ export class ChatPetWidget extends Disposable { break; } })); + this._register(this.chatPetService.onDidUnlockAchievement(id => { + if (!this._enabled || !this.hostService.hasFocus || this.chatPetService.onTheRun.get() || this._isDead.get()) { + return; + } + this._showTransientState('achievementUnlocked', false); + status(localize('chatPet.achievement.unlockedStatus', "Achievement unlocked: {0}. Activate the VS Code pet to view achievements.", getChatPetAchievement(id).title)); + })); const motionReduced = observableFromEvent(this, this.accessibilityService.onDidChangeReducedMotion, () => this.accessibilityService.isMotionReduced()); const targetWindow = dom.getWindow(this._button.element); @@ -1258,10 +1359,15 @@ export class ChatPetWidget extends Disposable { const variant = this.chatPetService.variant.read(reader); const variantChanged = variant !== this._variant; this._variant = variant; + const selectedAccessory = this.chatPetService.selectedAccessory.read(reader); + const accessoryChanged = selectedAccessory !== this._selectedAccessory; + this._selectedAccessory = selectedAccessory; + if (accessoryChanged) { + this._switchAccessory(selectedAccessory); + } const onTheRun = this.chatPetService.onTheRun.read(reader); const isDead = this._isDead.read(reader); this._button.element.classList.toggle('on-the-run', onTheRun); - this._button.setAriaLabel(this._getAriaLabel(onTheRun)); const chatModel = model.read(reader); const request = chatModel?.lastRequestObs.read(reader); const needsInput = !!request?.response?.isPendingConfirmation.read(reader); @@ -1280,6 +1386,7 @@ export class ChatPetWidget extends Disposable { this._busy = hasActiveRequest || needsInput; let idleExpired = this._idleExpired.read(reader); let transientState = this._transientState.read(reader); + this._button.setAriaLabel(this._getAriaLabel(onTheRun, transientState === 'achievementUnlocked')); const isDragging = this._isDragging.read(reader); if (!this._enablementInitialized || enabled !== this._enabled) { @@ -1737,6 +1844,13 @@ export class ChatPetWidget extends Disposable { const onTheRun = this.chatPetService.onTheRun.get(); const actions = new DisposableStore(); this._contextMenuActions.value = actions; + const achievements = actions.add(new Action( + 'chat.pet.achievements', + localize('chatPet.achievements.action', "Achievements…"), + undefined, + 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'))); @@ -1766,6 +1880,7 @@ export class ChatPetWidget extends Disposable { this.contextMenuService.showContextMenu({ getAnchor: () => new StandardMouseEvent(dom.getWindow(this._button.element), event), getActions: (): IAction[] => [ + achievements, onTheRunAction, interactionSeparator, grow, @@ -1829,10 +1944,12 @@ export class ChatPetWidget extends Disposable { : localize('chatPet.movedRight', "VS Code pet moved right")); } - private _getAriaLabel(onTheRun: boolean): string { - return onTheRun - ? localize('chatPet.restore', "Bring back the VS Code pet") - : localize('chatPet.interact', "Interact with the VS Code pet. Drag it around the chat, or flick it toward either side to throw it. Use the left and right arrow keys to make it hop, or hold Shift to throw it toward a wall. Use the context menu to put it on the run."); + private _getAriaLabel(onTheRun: boolean, achievementUnlocked: boolean): string { + return achievementUnlocked + ? localize('chatPet.openAchievements', "Open pet achievements. A new achievement is unlocked.") + : onTheRun + ? localize('chatPet.restore', "Bring back the VS Code pet") + : localize('chatPet.interact', "Interact with the VS Code pet. Drag it around the chat, or flick it toward either side to throw it. Use the left and right arrow keys to make it hop, or hold Shift to throw it toward a wall. Use the context menu to put it on the run."); } private _getCurrentLeft(): number { @@ -2069,9 +2186,11 @@ export class ChatPetWidget extends Disposable { const inputBounds = this.dragBounds.getBoundingClientRect(); this._button.element.classList.toggle('speech-bubble-left', shouldPlaceChatPetSpeechBubbleLeft(this._renderedState, buttonBounds.right, inputBounds.right, this._scale)); const wideSpriteOffset = getChatPetWideSpriteHorizontalOffset(this._renderedState, this._facingController.direction, buttonBounds.left, buttonBounds.right, inputBounds.left, inputBounds.right, this._scale); - if (this._activeSprite) { - this._activeSprite.container.style.transform = wideSpriteOffset === 0 ? '' : `translateX(${wideSpriteOffset}px)`; - } + setChatPetWideLayerOffset(wideSpriteOffset, [ + ...(this._activeSprite ? [this._activeSprite.container] : []), + this._eyes, + this._eyeAccessoryContainer, + ]); } private _updateGaze(): void { @@ -2082,7 +2201,7 @@ export class ChatPetWidget extends Disposable { const bounds = this._button.element.getBoundingClientRect(); const facingDirection = this._facingController.update(this._cursorPosition[0], bounds.left + bounds.width / 2); if (this._button.element.dataset.facing !== facingDirection) { - this._button.element.dataset.facing = facingDirection; + this._setFacingDirection(facingDirection); this._recordDirectionChange(facingDirection); } const [x, y] = getChatPetGazeDirection( @@ -2094,6 +2213,8 @@ export class ChatPetWidget extends Disposable { for (const pupil of this._pupils) { pupil.style.transform = `translate(${x * 2}px, ${y * 2}px)`; } + this._eyeAccessoryGazeOffset = getChatPetEyeAccessoryGazeOffset([x, y]); + this._redrawEyeAccessory?.(); } private _snapFacingToCursor(): void { @@ -2106,8 +2227,13 @@ export class ChatPetWidget extends Disposable { } private _setFacingDirection(direction: ChatPetFacingDirection): void { + const changed = this._button.element.dataset.facing !== direction; this._facingController.setDirection(direction); this._button.element.dataset.facing = direction; + if (changed) { + this._redrawActiveFrame?.(); + this._updateSpeechBubblePosition(); + } } private _recordDirectionChange(direction: ChatPetFacingDirection): boolean { @@ -2187,17 +2313,30 @@ export class ChatPetWidget extends Disposable { this._respawnPosition = undefined; this._spriteAnimation.clear(); this._speechAnimation.clear(); + this._redrawEyeAccessory = undefined; + this._eyeAccessoryGazeOffset = [0, 0]; + this._eyeAccessoryVisible = false; + this._eyeAccessoryContainer.classList.add('hidden'); + this._eyeAccessory.getContext('2d')?.clearRect(0, 0, this._eyeAccessory.width, this._eyeAccessory.height); this._speechBubble.container.classList.add('hidden'); this._speechBubble.image.removeAttribute('src'); - this._pendingSprite = undefined; - this._pendingSource = undefined; - this._pendingState = undefined; + this._pendingRender = undefined; + this._pendingAccessorySwitch = undefined; + this._renderGeneration++; + this._accessoryGeneration++; this._activeSprite = undefined; + this._activeSource = undefined; + this._redrawActiveFrame = undefined; this._renderedState = undefined; this._directionChangeController.reset(); for (const sprite of this._sprites) { sprite.container.classList.add('hidden'); sprite.image.removeAttribute('src'); + sprite.activeAccessory = undefined; + sprite.activeAccessoryImage = undefined; + for (const accessoryImage of sprite.accessoryImages ?? []) { + accessoryImage.removeAttribute('src'); + } } } @@ -2205,6 +2344,9 @@ export class ChatPetWidget extends Disposable { if (!this.chatPetService.enabled.get()) { return; } + if (this._transientState.get() === 'achievementUnlocked' && state !== 'achievementUnlocked') { + return; + } if (snapFacingToCursor) { this._snapFacingToCursor(); @@ -2265,9 +2407,8 @@ export class ChatPetWidget extends Disposable { const sources = getSpriteSources(this._variant)[state]; const source = this._motionReduced || useStaticSprite ? sources.reducedMotion : sources.animated; if (!restart && this._activeSprite && isChatPetImageSource(this._activeSprite.image, source.url)) { - this._pendingSprite = undefined; - this._pendingSource = undefined; - this._pendingState = undefined; + this._pendingRender = undefined; + this._renderGeneration++; this._button.element.dataset.state = state; this._renderedState = state; this._setRenderedFacingState(state, useStaticSprite); @@ -2281,46 +2422,250 @@ export class ChatPetWidget extends Disposable { return; } - this._pendingSprite = sprite; - this._pendingSource = source; - this._pendingState = state; + const accessorySource = this._getAccessoryImageSource(); + const cachedAccessoryImage = accessorySource + ? sprite.accessoryImages?.find(candidate => isChatPetImageSource(candidate, accessorySource.url) && candidate.complete && candidate.naturalWidth > 0) + : undefined; + const accessoryImage = accessorySource && !this._failedAccessorySources.has(accessorySource.url) + ? cachedAccessoryImage ?? sprite.accessoryImages?.find(candidate => candidate !== sprite.activeAccessoryImage) + : undefined; + const generation = ++this._renderGeneration; + this._pendingRender = { + generation, + sprite, + bodySource: source, + accessorySource: accessoryImage ? accessorySource : undefined, + accessoryImage, + accessory: accessoryImage ? this._selectedAccessory : undefined, + state, + useStaticSprite, + }; sprite.image.removeAttribute('src'); sprite.image.src = source.url; + if (accessoryImage && accessorySource && accessoryImage !== cachedAccessoryImage) { + accessoryImage.removeAttribute('src'); + accessoryImage.src = accessorySource.url; + } } - private _onImageLoad(sprite: ChatPetSpriteElement): void { - if (sprite !== this._pendingSprite || this._pendingSource === undefined || !isChatPetImageSource(sprite.image, this._pendingSource.url) || this._pendingState === undefined) { + private _getAccessoryImageSource(): IChatPetAccessoryImageSource | undefined { + if (!this._selectedAccessory) { + return undefined; + } + return getChatPetAccessoryImageSource(getChatPetAccessory(this._selectedAccessory)); + } + + private _onBodyImageLoad(sprite: ChatPetSpriteElement): void { + if (sprite !== this._pendingRender?.sprite) { return; } + this._tryCompletePendingRender(); + } + + private _onBodyImageError(sprite: ChatPetSpriteElement): void { + const pendingRender = this._pendingRender; + if (!pendingRender || pendingRender.sprite !== sprite || !isChatPetImageSource(sprite.image, pendingRender.bodySource.url)) { + return; + } + this.logService.error(`[ChatPetWidget] Failed to load pet sprite: ${pendingRender.bodySource.url}`); + this._pendingRender = undefined; + } + + private _onAccessoryImageLoad(sprite: ChatPetSpriteElement, image: HTMLImageElement): void { + if (this._pendingRender?.sprite === sprite && this._pendingRender.accessoryImage === image) { + this._tryCompletePendingRender(); + } + if (this._pendingAccessorySwitch?.sprite === sprite && this._pendingAccessorySwitch.image === image) { + this._tryCompleteAccessorySwitch(); + } + } + + private _onAccessoryImageError(sprite: ChatPetSpriteElement, image: HTMLImageElement): void { + const pendingRender = this._pendingRender; + if (pendingRender?.sprite === sprite && pendingRender.accessoryImage === image && pendingRender.accessorySource) { + this._recordAccessoryFailure(pendingRender.accessorySource.url, 'load'); + this._pendingRender = { + ...pendingRender, + accessorySource: undefined, + accessoryImage: undefined, + accessory: undefined, + }; + this._tryCompletePendingRender(); + } + const pendingSwitch = this._pendingAccessorySwitch; + if (pendingSwitch?.sprite === sprite && pendingSwitch.image === image) { + this._recordAccessoryFailure(pendingSwitch.source.url, 'load'); + this._completeAccessorySwitchWithoutAccessory(pendingSwitch); + } + } + + private _tryCompletePendingRender(): void { + const pendingRender = this._pendingRender; + if (!pendingRender || pendingRender.generation !== this._renderGeneration || !isChatPetImageSource(pendingRender.sprite.image, pendingRender.bodySource.url) || !pendingRender.sprite.image.complete || pendingRender.sprite.image.naturalWidth === 0) { + return; + } + const frameHeight = pendingRender.bodySource.frameHeight ?? CHAT_PET_SOURCE_SIZE; + const frameCount = Math.max(1, pendingRender.bodySource.frameDurations.length); + if (!hasChatPetBodyImageDimensions(pendingRender.sprite.image, pendingRender.bodySource.frameWidth, frameHeight, frameCount)) { + this.logService.error(`[ChatPetWidget] Invalid pet sprite dimensions: ${pendingRender.bodySource.url}`); + this._pendingRender = undefined; + return; + } + if (pendingRender.accessorySource && pendingRender.accessoryImage) { + if (!isChatPetImageSource(pendingRender.accessoryImage, pendingRender.accessorySource.url) || !pendingRender.accessoryImage.complete || pendingRender.accessoryImage.naturalWidth === 0) { + return; + } + if (!hasChatPetAccessoryImageDimensions(pendingRender.accessoryImage, pendingRender.accessorySource)) { + this._recordAccessoryFailure(pendingRender.accessorySource.url, 'dimensions'); + this._pendingRender = { + ...pendingRender, + accessorySource: undefined, + accessoryImage: undefined, + accessory: undefined, + }; + this._tryCompletePendingRender(); + return; + } + } this._spriteAnimation.clear(); - this._activeSprite?.container.classList.add('hidden'); - sprite.container.classList.remove('hidden'); - this._activeSprite = sprite; - const state = this._pendingState; + const previousSprite = this._activeSprite; + previousSprite?.container.classList.add('hidden'); + pendingRender.sprite.container.classList.remove('hidden'); + pendingRender.sprite.activeAccessory = pendingRender.accessory; + pendingRender.sprite.activeAccessoryImage = pendingRender.accessoryImage; + this._activeSprite = pendingRender.sprite; + this._activeSource = pendingRender.bodySource; + this._pendingAccessorySwitch = undefined; + this._accessoryGeneration++; + const state = pendingRender.state; this._startSpriteAnimation( - this._pendingSource, - sprite, + pendingRender.bodySource, + pendingRender.sprite, this._spriteAnimation, - () => this._onSpriteAnimationComplete(sprite, state), + () => this._onSpriteAnimationComplete(pendingRender.sprite, state), false, frameIndex => { - if (sprite === this._activeSprite) { + if (pendingRender.sprite === this._activeSprite) { this._updateEyes(state, frameIndex); } - } + }, + state ); this._button.element.dataset.state = state; this._renderedState = state; this._setRenderedFacingState(state, this._isDragging.get()); this._updateEyes(state); this._updateSpeechBubble(state, true); - this._pendingSprite = undefined; - this._pendingSource = undefined; - this._pendingState = undefined; + this._pendingRender = undefined; + this._clearUnusedAccessoryImages(pendingRender.sprite, pendingRender.accessorySource?.url); + if (previousSprite) { + if (!pendingRender.accessorySource || !previousSprite.activeAccessoryImage || !isChatPetImageSource(previousSprite.activeAccessoryImage, pendingRender.accessorySource.url)) { + previousSprite.activeAccessory = undefined; + previousSprite.activeAccessoryImage = undefined; + } + this._clearUnusedAccessoryImages(previousSprite, pendingRender.accessorySource?.url); + } this._restartEyeAnimation(); } + private _switchAccessory(accessory: ChatPetAccessoryId | undefined): void { + const pendingRender = this._pendingRender; + if (pendingRender) { + this._renderState(pendingRender.state, true, pendingRender.useStaticSprite); + } + const sprite = this._activeSprite; + const bodySource = this._activeSource; + const state = this._renderedState; + const generation = ++this._accessoryGeneration; + this._pendingAccessorySwitch = undefined; + if (!sprite || !bodySource || !state) { + return; + } + if (!accessory) { + sprite.activeAccessory = undefined; + sprite.activeAccessoryImage = undefined; + this._clearAllAccessoryImages(); + this._redrawActiveFrame?.(); + return; + } + + const source = getChatPetAccessoryImageSource(getChatPetAccessory(accessory)); + if (this._failedAccessorySources.has(source.url)) { + sprite.activeAccessory = undefined; + sprite.activeAccessoryImage = undefined; + this._clearAllAccessoryImages(); + this._redrawActiveFrame?.(); + return; + } + const image = sprite.accessoryImages?.find(candidate => candidate !== sprite.activeAccessoryImage); + if (!image) { + return; + } + this._pendingAccessorySwitch = { generation, sprite, source, image, accessory }; + image.removeAttribute('src'); + image.src = source.url; + } + + private _tryCompleteAccessorySwitch(): void { + const pendingSwitch = this._pendingAccessorySwitch; + if (!pendingSwitch || pendingSwitch.generation !== this._accessoryGeneration || pendingSwitch.accessory !== this._selectedAccessory || !isChatPetImageSource(pendingSwitch.image, pendingSwitch.source.url) || !pendingSwitch.image.complete || pendingSwitch.image.naturalWidth === 0) { + return; + } + if (!hasChatPetAccessoryImageDimensions(pendingSwitch.image, pendingSwitch.source)) { + this._recordAccessoryFailure(pendingSwitch.source.url, 'dimensions'); + this._completeAccessorySwitchWithoutAccessory(pendingSwitch); + return; + } + pendingSwitch.sprite.activeAccessory = pendingSwitch.accessory; + pendingSwitch.sprite.activeAccessoryImage = pendingSwitch.image; + this._pendingAccessorySwitch = undefined; + this._clearAllAccessoryImages(source => source === pendingSwitch.source.url); + this._redrawActiveFrame?.(); + } + + private _completeAccessorySwitchWithoutAccessory(pendingSwitch: ChatPetPendingAccessorySwitch): void { + if (this._pendingAccessorySwitch !== pendingSwitch) { + return; + } + pendingSwitch.sprite.activeAccessory = undefined; + pendingSwitch.sprite.activeAccessoryImage = undefined; + this._pendingAccessorySwitch = undefined; + this._clearAllAccessoryImages(); + this._redrawActiveFrame?.(); + } + + private _recordAccessoryFailure(url: string, reason: 'load' | 'dimensions'): void { + if (this._failedAccessorySources.has(url)) { + return; + } + this._failedAccessorySources.add(url); + this.logService.error(`[ChatPetWidget] Failed chat pet accessory ${reason === 'load' ? 'load' : 'dimension validation'}: ${url}`); + } + + private _clearUnusedAccessoryImages(sprite: ChatPetSpriteElement, keepSourceUrl: string | undefined): void { + for (const image of sprite.accessoryImages ?? []) { + if (!keepSourceUrl || !isChatPetImageSource(image, keepSourceUrl)) { + image.removeAttribute('src'); + } + } + } + + private _clearAllAccessoryImages(keepSource?: (source: string) => boolean): void { + for (const sprite of this._sprites) { + for (const image of sprite.accessoryImages ?? []) { + const source = image.getAttribute('src'); + if (!source || !keepSource?.(source)) { + image.removeAttribute('src'); + if (sprite.activeAccessoryImage === image) { + sprite.activeAccessory = undefined; + sprite.activeAccessoryImage = undefined; + } + } + } + } + } + private _setRenderedFacingState(state: ChatPetState, isDragging: boolean): void { this._facingController.setState(state, isDragging); if (!isDragging && doesChatPetStateTrackCursor(state)) { @@ -2334,6 +2679,10 @@ export class ChatPetWidget extends Disposable { this._eyes.classList.toggle('tracking', tracksCursor); this._eyes.classList.toggle('blinking', blinking); this._blinkController.setEnabled(this._enabled && !this._motionReduced && !dom.getWindow(this._eyes).document.hidden && (tracksCursor || blinking)); + if (!tracksCursor && (this._eyeAccessoryGazeOffset[0] !== 0 || this._eyeAccessoryGazeOffset[1] !== 0)) { + this._eyeAccessoryGazeOffset = [0, 0]; + this._redrawEyeAccessory?.(); + } if (blinking) { for (const pupil of this._pupils) { pupil.style.transform = ''; @@ -2357,7 +2706,7 @@ export class ChatPetWidget extends Disposable { this._renderedState = 'searchingDown'; } - private _startSpriteAnimation(source: ChatPetSpriteSource, sprite: ChatPetSpriteElement, animationDisposable: MutableDisposable<IDisposable>, onComplete?: () => void, reverse = false, onFrame?: (frameIndex: number) => void): void { + private _startSpriteAnimation(source: ChatPetSpriteSource, sprite: ChatPetSpriteElement, animationDisposable: MutableDisposable<IDisposable>, onComplete?: () => void, reverse = false, onFrame?: (frameIndex: number) => void, state?: ChatPetState): void { const { frameDurations } = source; const { image, canvas } = sprite; const displaySize = sprite === this._speechBubble ? 72 : sprite === this._respawnEffect ? this._getDisplaySize() : 48; @@ -2377,62 +2726,39 @@ export class ChatPetWidget extends Disposable { } context.imageSmoothingEnabled = false; const drawFrame = (frameIndex: number) => { - context.clearRect(0, 0, source.frameWidth, frameHeight); - const sourceX = frameIndex * source.frameWidth; - if (source.fixedOrientationDecorations !== undefined && this._facingController.direction === 'left') { - context.clearRect(0, 0, source.frameWidth, frameHeight); - context.save(); - context.translate(source.frameWidth, 0); - context.scale(-1, 1); - context.drawImage( + if (state) { + const activeAccessory = sprite.activeAccessory ? getChatPetAccessory(sprite.activeAccessory) : undefined; + drawChatPetComposite( + context, image, - sourceX, - 0, + sprite.activeAccessoryImage, + frameIndex, + source.accessoryRigFrame ?? frameIndex, source.frameWidth, frameHeight, - 0, - 0, - source.frameWidth, - frameHeight + this._facingController.direction, + state, + source.fixedOrientationDecorations, + false, + activeAccessory?.eyeAccessoryMirrorsWithFacing !== false, + activeAccessory?.coversAntennae === true, ); - context.restore(); - for (let decorationIndex = 0; decorationIndex < source.fixedOrientationDecorations.length; decorationIndex++) { - const decoration = source.fixedOrientationDecorations[decorationIndex]; - const currentBounds = decoration.frameBounds[frameIndex]; - const canonicalBounds = decoration.frameBounds[decoration.sourceFrame]; - const [currentLeft, currentTop, currentRight, currentBottom] = currentBounds; - const [canonicalLeft, canonicalTop, canonicalRight, canonicalBottom] = canonicalBounds; - const canonicalWidth = canonicalRight - canonicalLeft; - const canonicalHeight = canonicalBottom - canonicalTop; - context.clearRect(source.frameWidth - currentRight, currentTop, currentRight - currentLeft, currentBottom - currentTop); - context.drawImage( - image, - decoration.sourceFrame * source.frameWidth + canonicalLeft, - canonicalTop, - canonicalWidth, - canonicalHeight, - source.frameWidth - currentLeft - canonicalWidth, - currentTop, - canonicalWidth, - canonicalHeight - ); - } - onFrame?.(frameIndex); - return; + 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); + } + if (sprite === this._activeSprite) { + this._activeFrameIndex = frameIndex; } - context.drawImage( - image, - sourceX, - 0, - source.frameWidth, - frameHeight, - 0, - 0, - source.frameWidth, - frameHeight - ); onFrame?.(frameIndex); }; + if (sprite === this._activeSprite) { + this._redrawActiveFrame = () => drawFrame(this._activeFrameIndex); + this._redrawEyeAccessory = state + ? () => this._drawEyeAccessory(sprite.activeAccessory, sprite.activeAccessoryImage, source, state, source.accessoryRigFrame ?? this._activeFrameIndex) + : undefined; + } const initialFrameIndex = reverse && frameDurations.length > 0 ? frameDurations.length - 1 : 0; drawFrame(initialFrameIndex); if (frameDurations.length < 2) { @@ -2482,9 +2808,60 @@ export class ChatPetWidget extends Disposable { animationDisposable.value = animationDisposables; } + private _drawEyeAccessory(accessoryId: ChatPetAccessoryId | undefined, accessoryImage: HTMLImageElement | undefined, source: ChatPetSpriteSource, state: ChatPetState, rigFrameIndex: number): void { + const frameHeight = source.frameHeight ?? CHAT_PET_SOURCE_SIZE; + const visible = accessoryImage !== undefined && getChatPetAccessoryRigFrame(state, rigFrameIndex).rightEye !== undefined; + if (visible !== this._eyeAccessoryVisible) { + this._eyeAccessoryVisible = visible; + this._eyeAccessoryContainer.classList.toggle('hidden', !visible); + } + if (!visible || !accessoryImage) { + return; + } + + const accessory = accessoryId ? getChatPetAccessory(accessoryId) : undefined; + const mirrorsWithFacing = accessory?.eyeAccessoryMirrorsWithFacing !== false; + const fixedOrientation = !mirrorsWithFacing; + if (fixedOrientation !== this._eyeAccessoryFixedOrientation) { + this._eyeAccessoryFixedOrientation = fixedOrientation; + this._eyeAccessoryContainer.classList.toggle('fixed-orientation', fixedOrientation); + } + + const dimensions = this._eyeAccessoryDimensions; + if (!dimensions || dimensions.frameWidth !== source.frameWidth || dimensions.frameHeight !== frameHeight) { + this._eyeAccessoryDimensions = { frameWidth: source.frameWidth, frameHeight }; + const displayScale = 48 / CHAT_PET_SOURCE_SIZE; + this._eyeAccessory.width = source.frameWidth; + this._eyeAccessory.height = frameHeight; + this._eyeAccessoryContainer.style.width = `${source.frameWidth * displayScale}px`; + this._eyeAccessoryContainer.style.height = `${frameHeight * displayScale}px`; + this._eyeAccessory.style.width = `${source.frameWidth * displayScale}px`; + this._eyeAccessory.style.height = `${frameHeight * displayScale}px`; + } + + const context = this._eyeAccessory.getContext('2d'); + if (!context) { + return; + } + context.imageSmoothingEnabled = false; + const facingDirection = source.fixedOrientationDecorations || !mirrorsWithFacing ? this._facingController.direction : 'right'; + drawChatPetEyeAccessory( + context, + accessoryImage, + state, + rigFrameIndex, + facingDirection, + mirrorsWithFacing, + mirrorsWithFacing ? undefined : this._eyeAccessoryGazeOffset, + ); + } + private _updateSpeechBubble(state: ChatPetState | undefined, restart = false): void { this._updateSpeechBubblePosition(); const visible = doesChatPetStateSpeak(state); + const speechBubbleState = visible ? state as 'rendering' | 'achievementUnlocked' : undefined; + const stateChanged = speechBubbleState !== this._speechBubbleState; + this._speechBubbleState = speechBubbleState; this._speechBubble.container.classList.toggle('hidden', !visible); if (!visible) { this._speechAnimation.clear(); @@ -2499,9 +2876,21 @@ export class ChatPetWidget extends Disposable { this._speechBubble.image.src = source.url; return; } - if (restart && this._speechBubble.image.complete && this._speechBubble.image.naturalWidth > 0) { + if ((restart || stateChanged) && this._speechBubble.image.complete && this._speechBubble.image.naturalWidth > 0) { this._speechAnimation.clear(); - this._startSpriteAnimation(source, this._speechBubble, this._speechAnimation); + this._startSpriteAnimation( + source, + this._speechBubble, + this._speechAnimation, + undefined, + false, + state === 'achievementUnlocked' ? () => { + const context = this._speechBubble.canvas.getContext('2d'); + if (context) { + drawChatPetAchievementStar(context, this._variant); + } + } : undefined, + ); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 322ec7b0222abf..124e22682a6519 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -105,6 +105,7 @@ import { CHAT_READ_ONLY_BANNER_HEIGHT, ChatReadOnlyBanner } from './chatReadOnly import { IChatSubmitRequestHandlerService } from '../chatSubmitRequestHandlerService.js'; import { ChatPetWidget, shouldReserveChatPetSpace } from './chatPetWidget.js'; import { IChatPetService } from '../chatPetService.js'; +import { ChatPetAchievementIds, hasChatPetImageAttachment } from '../chatPetAchievements.js'; import { stopDictationForEditor } from '../speechToText/dictationSession.js'; import { ChatContentMarkdownRenderer } from './chatContentMarkdownRenderer.js'; @@ -207,6 +208,14 @@ export async function acceptAndAwaitSentRequest(result: ChatSendResult, onReques return ChatSendResult.isSent(sent) ? sent : undefined; } +export function shouldUnlockChatPetRequestRevision(isEditing: boolean, isUserQuery: boolean): boolean { + return isEditing && isUserQuery; +} + +export function shouldUnlockChatPetQueueOrSteeringMessage(isUserQuery: boolean, queue: ChatRequestQueueKind | undefined): boolean { + return isUserQuery && queue !== undefined; +} + type ChatHandoffClickEvent = { fromAgent: string; toAgent: string; @@ -3114,6 +3123,7 @@ export class ChatWidget extends Disposable implements IChatWidget { return; } const isEditing = this.viewModel?.editing; + const submittedFromEditing = shouldUnlockChatPetRequestRevision(isEditing !== undefined, isUserQuery); // Captured before `finishedEditing` tears the inline editor down, while `this.input` still // resolves to it. The inline editor owns the model and mode for a resubmit — those are the // pickers the user actually chose in — so these stay authoritative over the bottom input. @@ -3236,6 +3246,10 @@ export class ChatWidget extends Disposable implements IChatWidget { // Expand directory attachments: extract images as binary entries const resolvedImageVariables = await this._resolveDirectoryImageAttachments(requestInputs.attachedContext.asArray()); + const submittedWithImage = isUserQuery && hasChatPetImageAttachment([ + ...requestInputs.attachedContext.asArray(), + ...resolvedImageVariables, + ]); const submittedSessionResource = this.viewModel.sessionResource; // For contributed session types, only collect automatic instructions when @@ -3303,10 +3317,25 @@ export class ChatWidget extends Disposable implements IChatWidget { this._maybeStartGoalSummary(requestInputs.input); } - const sent = await acceptAndAwaitSentRequest(result, options.onRequestAccepted); + const shouldUnlockQueueOrSteeringMessage = shouldUnlockChatPetQueueOrSteeringMessage(isUserQuery, options.queue); + const sent = await acceptAndAwaitSentRequest(result, () => { + if (shouldUnlockQueueOrSteeringMessage) { + this.chatPetService.unlockAchievement(ChatPetAchievementIds.QueueOrSteeringMessage); + } + options.onRequestAccepted?.(); + }); if (!sent) { return; } + if (isUserQuery) { + this.chatPetService.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + } + if (submittedFromEditing) { + this.chatPetService.unlockAchievement(ChatPetAchievementIds.RequestRevision); + } + if (submittedWithImage) { + this.chatPetService.unlockAchievement(ChatPetAchievementIds.ImageRequest); + } if (!options.preserveInput) { // Not a user submission; listeners would consume draft state. Also skips editor pinning. diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 6ea63cf3ac1a6f..6689b36a781639 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -161,6 +161,8 @@ import { IChatInputNoticeHubService } from './chatInputNoticeHub.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, setChatInputStackInputFocused, setChatInputStackSlot } from './chatInputStack.js'; import { ChatSelectedTools } from './chatSelectedTools.js'; +import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../chatPetAchievements.js'; +import { IChatPetService } from '../../chatPetService.js'; import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; import { ModelPickerActionItem, IModelPickerDelegate, IModelPickerPresentationOptions } from './modelPicker/modelPickerActionItem.js'; import { IModePickerDelegate, isModeConsideredBuiltIn, ModePickerActionItem } from './modePickerActionItem.js'; @@ -826,6 +828,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, @IChatService private readonly chatService: IChatService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IChatPetService private readonly chatPetService: IChatPetService, ) { super(); this._modelSelectionDiagnostics = new ChatModelSelectionDiagnostics(this.logService, this.storageService, () => ({ @@ -1276,7 +1279,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return { currentModel: this._currentLanguageModel, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { + const previousModelIdentifier = this._currentLanguageModel.get()?.identifier; this.setCurrentLanguageModel(model, true, !this.options.suppressModelPersistence); + if (didExplicitlySwitchChatPetModel(previousModelIdentifier, model.identifier)) { + this.chatPetService.unlockAchievement(ChatPetAchievementIds.ModelSwitch); + } this.renderAttachedContext(); }, getModels: () => this.getModels(), 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 5e509076da90ae..b24f3b7bd38f20 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css @@ -138,16 +138,46 @@ display: none; } -.chat-pet-button[data-facing='left'] .chat-pet-sprite { +.chat-pet-eye-accessory { + position: absolute; + bottom: 0; + left: 0; + display: block; + width: 48px; + height: 48px; + overflow: hidden; + image-rendering: pixelated; + pointer-events: none; +} + +.chat-pet-eye-accessory.hidden { + display: none; +} + +.chat-pet-eye-accessory-canvas { + display: block; + height: 48px; + image-rendering: pixelated; + pointer-events: none; +} + +.chat-pet-button[data-facing='left'] .chat-pet-sprite, +.chat-pet-button[data-facing='left'] .chat-pet-eye-accessory { right: 0; left: auto; } -.chat-pet-button[data-facing='left'] .chat-pet-sprite > .chat-pet-canvas { +.chat-pet-button[data-facing='left'] .chat-pet-sprite > .chat-pet-canvas, +.chat-pet-button[data-facing='left'] .chat-pet-eye-accessory-canvas { transform: scaleX(-1); } -.chat-pet-button[data-facing='left'][data-state='sing'] .chat-pet-sprite > .chat-pet-canvas { +.chat-pet-button[data-facing='left'] .chat-pet-eye-accessory.fixed-orientation .chat-pet-eye-accessory-canvas { + transform: none; +} + +.chat-pet-button[data-facing='left'][data-state='sing'] .chat-pet-sprite > .chat-pet-canvas, +.chat-pet-button[data-facing='left'][data-state='sing'] .chat-pet-eye-accessory-canvas { transform: none; } @@ -205,26 +235,31 @@ display: none; } -.chat-pet-button[data-state='complete'] .chat-pet-sprite { +.chat-pet-button[data-state='complete'] .chat-pet-sprite, +.chat-pet-button[data-state='complete'] .chat-pet-eye-accessory { transform-origin: 50% 60%; animation: chat-pet-complete-motion 960ms steps(1, end); } -.chat-pet-button[data-state='jump'][data-hop-direction='left'] .chat-pet-sprite > .chat-pet-canvas { +.chat-pet-button[data-state='jump'][data-hop-direction='left'] .chat-pet-sprite > .chat-pet-canvas, +.chat-pet-button[data-state='jump'][data-hop-direction='left'] .chat-pet-eye-accessory-canvas { transform: scaleX(-1); } -.chat-pet-button[data-state='jump'][data-hop-direction='right'] .chat-pet-sprite > .chat-pet-canvas { +.chat-pet-button[data-state='jump'][data-hop-direction='right'] .chat-pet-sprite > .chat-pet-canvas, +.chat-pet-button[data-state='jump'][data-hop-direction='right'] .chat-pet-eye-accessory-canvas { transform: none; } .chat-pet-button[data-state='yapping'] .chat-pet-sprite, -.chat-pet-button[data-state='yapping'] .chat-pet-eyes { +.chat-pet-button[data-state='yapping'] .chat-pet-eyes, +.chat-pet-button[data-state='yapping'] .chat-pet-eye-accessory { transform-origin: 30% 80%; animation: chat-pet-yapping-fall 480ms steps(4, end) forwards; } -.chat-pet-button[data-state='yappingMouthOpen'] .chat-pet-sprite { +.chat-pet-button[data-state='yappingMouthOpen'] .chat-pet-sprite, +.chat-pet-button[data-state='yappingMouthOpen'] .chat-pet-eye-accessory { transform-origin: 30% 80%; transform: translateY(calc(-1 * var(--vscode-spacing-size40))) rotate(-90deg); } @@ -242,7 +277,8 @@ } .chat-pet-button.dragging .chat-pet-sprite, -.chat-pet-button.dragging .chat-pet-eyes { +.chat-pet-button.dragging .chat-pet-eyes, +.chat-pet-button.dragging .chat-pet-eye-accessory { transform: none; animation: none; } @@ -302,6 +338,7 @@ .chat-pet-button[data-state='idle'] .chat-pet-eye, .chat-pet-button[data-state='rendering'] .chat-pet-eye, +.chat-pet-button[data-state='achievementUnlocked'] .chat-pet-eye, .chat-pet-button[data-state='clapping'] .chat-pet-eye, .chat-pet-button[data-state='yapping'] .chat-pet-eye, .chat-pet-button[data-state='typing'] .chat-pet-eye, @@ -311,6 +348,7 @@ .chat-pet-button[data-state='idle'] .chat-pet-pupil, .chat-pet-button[data-state='rendering'] .chat-pet-pupil, +.chat-pet-button[data-state='achievementUnlocked'] .chat-pet-pupil, .chat-pet-button[data-state='clapping'] .chat-pet-pupil, .chat-pet-button[data-state='yapping'] .chat-pet-pupil, .chat-pet-button[data-state='typing'] .chat-pet-pupil, @@ -333,6 +371,7 @@ .chat-pet-button[data-state='idle'] .chat-pet-eyes.blink .chat-pet-pupil, .chat-pet-button[data-state='rendering'] .chat-pet-eyes.blink .chat-pet-pupil, +.chat-pet-button[data-state='achievementUnlocked'] .chat-pet-eyes.blink .chat-pet-pupil, .chat-pet-button[data-state='clapping'] .chat-pet-eyes.blink .chat-pet-pupil, .chat-pet-button[data-state='yapping'] .chat-pet-eyes.blink .chat-pet-pupil, .chat-pet-button[data-state='typing'] .chat-pet-eyes.blink .chat-pet-pupil, @@ -507,7 +546,9 @@ .monaco-workbench.monaco-reduce-motion .chat-pet-button.dragging.soft-resisting, .monaco-workbench.monaco-reduce-motion .chat-pet-button.dragging.resisting, .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='complete'] .chat-pet-sprite, +.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='complete'] .chat-pet-eye-accessory, .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='yapping'] .chat-pet-sprite, +.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='yapping'] .chat-pet-eye-accessory, .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='searching'], .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='searchingDown'], .monaco-workbench.monaco-reduce-motion .chat-pet-eyes, @@ -516,7 +557,8 @@ } .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='yapping'] .chat-pet-sprite, -.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='yapping'] .chat-pet-eyes { +.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='yapping'] .chat-pet-eyes, +.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='yapping'] .chat-pet-eye-accessory { transform: translateY(calc(-1 * var(--vscode-spacing-size40))) rotate(-90deg); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/artist-beret.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/artist-beret.png new file mode 100644 index 0000000000000000000000000000000000000000..2dc3580b6df56dcf07e50fa07142d445d8090936 GIT binary patch literal 904 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;X{XJbALn`LHy}Qx(aDa$I zU_0ZC2BE-97mb)rg%7ZtIlyATXli`nQlOHTS_0z?hUEs=%6B~9BG_3mvF7X2^z>Ok zlLirJ=(=0-VBb5reb3|U_wBd+@xSo&nR=bi>79#R>v<0x6k=%5U}We(rKI*L|Ea07 z-F<w|w$<C0KQxv&`RDk#_mAF`TztNNU%gMZ)tC3}m3|BiflLensu)yvdCgnL{?vc} ztM@H_zW?j{_S88H42xJ86udB~Z|^?bYL?s0p8GHPd3|w_<(Kb5e0Lu|@BYnVc7_w1 z2B0Zf+e^;RNv(eMY`@<5)$R8!85vwS862iCpi^(}PW)!`W#f5g^Z%(f{c?5BS+F^l z31?J&a4)O>>c(IiU(SEOc)k4n`+2{lUW*^Z;S;mHi@6M*|NK)`#$P|bd-G-2`Tu|Y zGhF-f)!WRg*;|CMIS}OP-wX|BrS?9z|7F*(^}TQb6M9TQ<E2QRVMC<3`t<I1%qyz@ q)Xdy_%o>Ncae8N9Bz*qg=1&jFm~KCr5ty|Y7(8A5T-G@yGywnyN!9@X literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/baseball-cap.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/baseball-cap.png new file mode 100644 index 0000000000000000000000000000000000000000..3ac0fac9038ef6bf96879229a76935a7493104d8 GIT binary patch literal 935 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;X^E_P~Ln`LHy=&;@Y{+o@ zVTg#0<Mu<l9m37OGFk1|VX?ECdHsVI@16wRDJd@~{>tRquh8@$AV1(>de6uIwXYXH zoVd`Q$umw9XyPCO4qYd2Fp6#dxhiDK=Yr@cskX!~GFMan-8TCFC1>$hZiWkO3=Azo z3=IKH3<6#Z3{IR34jPOM9HyT4pZ~f3-E;Q;ua}<J|9-IL%9pzGt>1PQGu3WpV&G6^ zU|7h)pfCkhZ|<~nTko;YJ@;q(*FWE)O8;bDy_k8H3qu*yimQJu8GbJSI^?m<JGs7J ze=pvvTlwlw(%Z-VlQ#$6_r9@f-xu4)wlxShp;>x&KJ$!w_qWuwX8f=H*B?Lc-#5Fj zua{rTcQ}q)PyD;WxHHcdzx%yz?XTJwQ)^4*cl}&{y7r3-PEX;A%DNLzma{Sa$nCya z6I%RdZu{*^mkfA9|1Yi;<UtP>sDHQK{Kd*pb@%!H+JdO8D81>cXFX@Z?b36M3^kWO z-_Bl{8C6=M&Cn2m8b8pmxUypR62_{3at9*ZtA8&|55pQQoVffjP~r|h@7pm7s06<5 SH}5e8Il|M`&t;ucLK6Unob3Go literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/construction-hard-hat.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/construction-hard-hat.png new file mode 100644 index 0000000000000000000000000000000000000000..00d6079e874c0e58eb326ef42a741e955806c145 GIT binary patch literal 937 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;X3p`yMLn`LHy=&;l<jBw# zsG#G%Vd17ti=OkT%{#6fQ79oSTs=E?&6*WE+F3rZyc0Q`Cid_9t+Na>CHDIm8C%b0 z1R6DnfabI0`h(x|3Rg<*ue9Q~`+rk^)v@*Sj?ZtNcBVf1*?djuf3F!I<Z&`MXfQHx zs4_4tWMNR4!obiX#Ly6M`WAmh{adczbNqe3TTVarziy8H_C5Z${%oIn|Eu%eKYoAb zG!?UaKg)&C!Nefo#lYYMHE!#iUv@9P)V_LFf9pBRgK3eB5Mxmlf$Us$pPga*ns0Ml zJzvgoUVF*1`(2^6P2IKd-v57JaKB$H@l3`&It<lqNQOLT==k=jY|rP~7qyQ+`pgu? z>KCB;EW2;DFOpa9<^Nmp!d*80)6c8CcIN*dpWFZYg=ZQoipvoJfh#IwPX39KXE89n zzx(LYv_H>Y+Wh#FzJG<({;Is+_BW&t!ovVz5+q2k*)u$_f4~0j1G|4;53kIU|99oQ zW>0P30i1z|W&o1Efe{v3I^D$K^zVPsw?*E)elNGZ`FwwS-Ib53xB?58Zw5&Gz?*%4 Y86Co=Oj<p!F9GBRPgg&ebxsLQ07Pizy8r+H literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/cowboy-hat.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/cowboy-hat.png new file mode 100644 index 0000000000000000000000000000000000000000..b2cff09b88514182c5009c3629aad62e3b43ef3b GIT binary patch literal 946 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;XD?MEtLn`LHy=&-|>?m{m z<H<><(gbyS%sIjoE109Z=N#kKVf3Hu)N$Nn>mKPGsl-HXZN+&9at!%C&)a`~dF>fv zzlr+H|M!H0EEz;#LejNg{BO+5mrwg1ue1JKoIT(A(AVL&r$4K{GU@O8)BlXq?5pY- z7=8&bGz2g)2zW6tIB_yKXfQHxs4_4te3tqs>-n6O$^U<E`hTT>2_neCpfH7jp+yLx z!}nh~!-Y$q(?Y-6{D1o)t}5#P-|Juh*WZxc<DYP34yutzx;I}dKH%^CGOM;WaO=N+ zpVoev)4c8f<$M35%(r(NrM`}@-p?#ysE)7($zecKuB`b!r}(wy*L%e;{?C5=@AL17 zDzE$X#jjL~ak|sAUfyBtm*)#_|F?Sf=V7rQ(1%FQfqNfU^c@hNS$nmY;aJ-Koz`3T z{7<SZ{r0P{Vr|s^Tl-A@_I-0Rz!rocm+tw_&M;$D^3yr>`MB(K(0I?x;P$$5^Qx+O z|1SsZii(d9S-XFK&FZNC8?5L2?JLLWsY-c<54pB_udm&|qvsWG|9aYczI1(SMwAFb jgd8rv4UqWL>fa3YYm?90R8IxwN(Kf`S3j3^P6<r_>{RL( literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/crown.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/crown.png new file mode 100644 index 0000000000000000000000000000000000000000..06fc8ec96d469382cd311a3dcab26bc2c2985d29 GIT binary patch literal 970 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;XCwjU#hE&XXd)F{a+EJqI zVQg~1%dpl1D??Wlva~t)Exx7hv6_2^jH9}zYRa7*1@F&kvJ^=(O_n~><le1Rd;ape z^0c4adv$u&tYZcmI*35f7w7s7(Y{`9e-#<7&HwPhY=?XIr<&l}egEDi-}xK={`K$k z+kJn}{(s=#v-*pl(__Mw*Yh8!_hMjh;$(2pU}WG>Wnfsy!k{pPfuUtrUhIRHPxn^s zwYfKKTJa_4@8x$BuK?-ibNtW#IbZ$n?X73`&tCib^!x1dHX`YVq%jN-VrU3pVh~vM zvzlS;F?)5}4gcTISN$oo<)^b=KEfKPD(r4Rm0Yo}wDE3M(WU?D|G%D|zwGz#_h09` zXzc$Nwm$x^##!T>#DB}@+Rrh@Y50n|H^t9RAKfB-b-VNJzne?{{;IpYrN;ig+1z0B z|Ci6n>&-NWI2pq+xT5%h$FF2vmV%t!SC?E_r!Ri_)!)B|Z?BkN`1I$pUp0U49^dhM z|Gn$q=jX5f{oB<592yu#D_r@yQl8<#=6P}VR$o8;IQQ$En%&Nqzq9PRxoh8_y34&M vaRw}=QJ`o;4KlPig-GJ^%>apCh}~8%c4^wpH(3kjf}G&#>gTe~DWM4fx|ag! literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/firefighter-helmet.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/firefighter-helmet.png new file mode 100644 index 0000000000000000000000000000000000000000..08c2d8e4c42a3b1cd9b1c99e234429ddb6725a1f GIT binary patch literal 1068 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;XKYO}3hE&XXdv~E<bfC=f zkGGflo-{IV&I~uuIH2Pp*J!De)o)<gdS~{8ReKpv1hQtOsV+GArRl}J2Qh_3#reN~ z*Su*I^JBh0=SlsoWsE>`2N4MB`Sp*{tbDTX^nQD*`qzKUWsH8`*Z=K5|K8VMZy3I~ zF)%oBGB{{3GH|FeFf3$YP?*BN(6VZt;J?xjJ1<*4oBVUpj(^`@Z`!fXe!j@{|F!>v zV(#yb<*Vwu^{@POT3O7U``w2ZFZ1U=?|<~`&9AFoecid=N_XA-wc*YGelIw~2{BHH zp&@{YLBI=Pm+7jx&2c@;LyMnmEk3u~Zh7AS-FN@r`YXHZ*7g0_wf8@JJZC{skK{Hf z{@Qap-hPa}&%gK6&)ahQ^S|vs_w@AR70>25ynS4r7yHkKVUr~zLkp&*AVXHYXFQ<y zy0%Z=>IrTew(h<EGsz}*Tjcfk)8k)1pMU>o>F@h%_ugJ=S)YX4=&1As^))sc_k=S( zzb(x@x2x$d`>sd0E!kdsaQS!nSMwfjeRuov&H4q}eu(f#b^D5I)eH=~vx?1E{@T-P zw)pdQ$9lPTVT?$FYRbA`yvkwQ?3;y!e*YhRSn=}m;m^T^wPky?*1bQke)Q|_)t4Xr zx>*(a)&367*m)|)U~t3o|IvzDIh%nAK+;GZBLZO&Qq6GS8qmkZKR>;wk7>l^s6mt; eKu)>vX50U37i;>u=Ra`-@jYGrT-G@yGywoz@-%7y literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/full-size-spinner-hat.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/full-size-spinner-hat.png new file mode 100644 index 0000000000000000000000000000000000000000..ea98fef1be0124ac69f83045039da254ddce8528 GIT binary patch literal 995 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;XS9!WPhE&XXdv~LkOrS*D z!<!QhU5nt@)Ve)ky55F^emObYE(Up|CuXN+CueJN#5K&_py=!IT(?7qTdZ^XhMmp9 z7ANd?-~YUY`E>=`x2Ff{({}R#4If0n!!SxlPG37e;qjZLm47oV-(I_U_v`yh`}6;< ztzSC-etplX_lh@{zmjHH(8j>fBE--Tz{DWn#lYai$>5;D$iQLxL-J1j=KQ_qG;3!5 zk^1xBBIVlu<hk}X=GV*jRlNTC^YPrX&%Um1jy<nFLl&Y#m4RU)3xmQGgmJ5C*lx&_ z=)S&x{@>FzU%s2HvW@cz`}K3_W@ZKsOw}OEYk%;(|MG05rTX9RTkp@QjE}zk*7ED` z%X_EJE8g~R>+9X`+WysK;MV?i-ovkUUv7P07+wE<ar^g8IPK{@7Vp34`H7Qe|BGMC zFm8N<@Hd*f_NFgbZtj2aImbKy?_SSOtgV0h>#!D12O!5k+qvCW8jZRSKfZK-^9_sY z%sBffzul$JR1Cnui)IZl>`z%TC|vt_xcIZop_-4^UVlDrJ^SqIaQ59x?;k9~2w7M} zJ!fc`y0zX?`>$<dR=@b<xcd)rs$FsZsyV}lIADl=e|tHN9VN06u0i&}AYhlgerq4_ WB(p;F*O^d|b39%BT-G@yGywp5mkm1r literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/grand-top-hat-monocle.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/grand-top-hat-monocle.png new file mode 100644 index 0000000000000000000000000000000000000000..8d77d879e475a0768b16ce4c5d25314d86025d94 GIT binary patch literal 1143 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ-s>^xl@Ln`LHy=#~yk|@#e zF!H3}r4F$q#psUKxn*UX6)e`p4Ati!@Y=L3i1pfN;JSfB^N5q6Vg;*h<K_oOe|G=2 z^WQz;X3f!ke?FW&W5NhDcn|^6)l2mYYSRCv-%2*OUne&I;-oY6I-k?0-<A5y${^v) zz_5sgLBR`!dX@1-yyD}lvu~bHpMP%quiy9g*UkL<=llBn;F&-BzrHy6dExc_e}3e> z_$qmt6J0OVq^uvc47cCS%rE->@9@p@ud81?+yCbCzxDU--TNSJ=kL6C&i}gCn=`-t z|Gcl^?=uz#0aXTuKqhp`^p9PG@A>ZZ$Y=ZK`c{ANkH0_r?&ABU-~RB=7G`jm!oc9d ziB4U~`t$ih`R!kCp8LJ`oW0++^hdlOHlsoIt@f>cQgGsqyTeSJraiL0z%Mud&DUDC z>*Dg?s{HT#X{;|~V(1WJXwYCpqmbkLM&Z1zd@^euTfg30Cx7g0|7zFz#dkS*uzR!A z<QF$X&nKjCor@A3NRhLLpTXkB^S|+X_u9VQ{PpH}*ZblRaE0DeJB9}dwiPu-)$<Rm z+r96{h8Ou;aC-FBv%i~Nzo+#3pTC=JaQM%0qyHO$$wVECH$i@1u{ig8#`JrS*BXPB zeU6`o%V4aL1Qon;>^oz{y>AiE?5F+gPyKhg2uHwnMCtBbUR_hQ@2lw#^Uw09L9Tmi z!H6aHK?dvYQ_l7;*MX|5#1;F<u|)yfp!5&xw!=HURHHuVfQ<2U^>bP0l+XkK3)N8D literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/leaning-party-hat.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/leaning-party-hat.png new file mode 100644 index 0000000000000000000000000000000000000000..b1a53d2da3be44d8ee7eb14f550101a9101996a8 GIT binary patch literal 973 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;Xr+B(JhE&XXd)Ls5*-@hH zVJv4vMF$6?euDIdOV<uLKVg}4DT?Lyodd_>lAbaavGga*R8n}sd55)q!{*<%{?eOv z^UavvSU+hQBhbh}1cJ8sKIPjzd;X;F^W*YX|Ci2A``e!S*K*n9#c{F?S)2?G8jK7a zstgPZSr`<iFfg<TF*F2hUMQbX|No-p^FMzt{r$K5b>(^4bN@evSg!fM;q~q3-&R-G z&X>Pey>5f<PFvx0b_M}21_mcglV)ADW4~*je$Sl!@3oZ8{~pKfzrIgeN8c*`p8n~1 z|KGek^`6sbhAdVGFfj<MlKnmB%bs+;s#nG5uI)dQwoxz2{?r;fdH28n&CAMA-3wF> zb;63WSH;hkw#^IP{_nBfaz@k5%ov`7D&G3C_?h$hmC^r=&T$_2f0Gg7bA(B-V7YZR zDb}Q&FL~Rqnv%YT2wXNJ$GF4O8gZ)w^1x7Ebl=-@@13x}lCM<^umv>Gx-7FV%nUN8 z|NUE>(f9vb_Pw9KJ|C_=FS^b5>>I413W}f5`f$?;YfJk6zuT45SHEvhMqA0ZlF}>Z zGpvD<m|g(IY%#-u1hekai*xK#ahZ$kg+ah>NV`?XczkY6)sI<$^FZ$LboFyt=akR{ E0L~8dXaE2J literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/sailor-hat.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/sailor-hat.png new file mode 100644 index 0000000000000000000000000000000000000000..dad7c897cdca638b6c290f1b6f1ecfcf9e74b81e GIT binary patch literal 836 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;XxjkJRLn`LHy=$0v$U(p% z(Avw{+gqyWzp*2;uI|#}7Lj97XX@WG6q<O{tlg5lj1#DH5P=ml-2XG5{uJY8S3i&c zaJs>ypZ%%-dcK%`dC9<|%)k)H#2}!GLYd~*_W!J^_o*(klfC~-ZtKr{>;L}q-`}&B zzgEb^&>_Uopuvbv{rcXwuKGgqd&k-TtIy9beZ$%Flm*>fs4ZKo_#1M;3hz7bzw^#+ zzGz*&-!}%fB3x!~VmFv!+puv@|2!jgY<2@3wBr5N?@RXb*G50%&pFD;py0*8u!seX z!WAVCT%U#iRczQBIqkX8r=Oo|B!0=h5kH8jA8hCwMuy#cm)GyLU$*J<%N<$g58$v& z;glT1hiz{A*Z;q4$;jZs$>1=B0iC+Z%ux2aX!mX5h=Vvx!R6!u5>NP*Q?I%pafWp8 Rud5&@c)I$ztaD0e0sx=ls;>Y5 literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/viking-helmet.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/viking-helmet.png new file mode 100644 index 0000000000000000000000000000000000000000..44943a8b3e5bed82c5778271e09ee70dee7faedd GIT binary patch literal 1185 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ-sGCW-zLn`LHy=&+v8Yt3` zIIoaVr}2`Mlyg${o0tae1&-kf2e~F36WzgJ?VRnDvtU~aXFmHbru*8*PM(?j|96yA z#hjL(#gmKM@BaJw@BB%Y<>~q5>WRV6&8PmH{eO~;{jqOJFZme`7_l%Ycrh?6LZ$lN zJ-GJ0=J`Eido%gZ=KnwanK@mU!C?vmg9|4*b>)fdor~X>xZf`}`+xXM{q4``QtAv1 z8jK7bLg>_r>Dl%3f8EmE`#k60v-o-Tmn|5v7znh5RIqw$^e<+H*x%28+XwIa|K&~Y zFUf85E%uB5f4`W)KnSZ-zVPX8$opE&^t*Hae?vb8hCn6;0aXmj`TYr|+{O2WZ=B!r zy#CGiVkRuU0(rG+=NEQ{8QJG+-`W0N>bv*&0~Tzi0j+rYpYcGCUF|;UD*nIUp4~D| zIy?s>@S#!h=EtG7??0wLlRLP}5=S_>oZYwB`j_0-7u9E;)!(>$y?fzv<6WOuA7A)? zpWAa>PS_e<b^gz%PhXeJ|Nr5`>m@b*2mT!2x7=`h@^_gx54YpA>g~3Fc0ZT;-VHYY zpLs{Pp&n;g+>))1x5%0K{bBGkTuw0k!}n!&xjf&!<R72@eLwjA|Bt^{53c|J=VR^< z$+IW_B*x&3klFV)+I{=aw}-zj?iv5<r?@OgWtYCO#((~MzI)D*yKm2a9XbCZP9xtj zGORZJCHMc<dqG_3(Pbw)gU+*;uUl>Zv*HXOpqdRAY}<js?>he=F55^=*)&$Ke*2ra X)ujVF0xqSdgRJs&^>bP0l+XkKnKoEr literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsContribution.test.ts new file mode 100644 index 00000000000000..98f113c0ff03c4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsContribution.test.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { constObservable, 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 { ILogService } from '../../../../../platform/log/common/log.js'; +import { ChatPetCustomizationAchievementContribution } from '../../browser/chatPetAchievements.contribution.js'; +import { IAICustomizationItemSource, IAICustomizationListItem } from '../../browser/aiCustomization/aiCustomizationItemSource.js'; +import { IAICustomizationItemsModel, ItemsModelSection } from '../../browser/aiCustomization/aiCustomizationItemsModel.js'; +import { ChatPetAchievementId, ChatPetAchievementIds } from '../../browser/chatPetAchievements.js'; +import { IChatPetService } from '../../browser/chatPetService.js'; +import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; + +suite('Chat Pet Customization Achievements', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function customization(id: string, section: PromptsType): IAICustomizationListItem { + return { + id, + uri: URI.file(`/customizations/${id}.md`), + name: id, + filename: `${id}.md`, + source: 'user', + promptType: section, + disabled: false, + }; + } + + function mcpServer(id: string): IWorkbenchMcpServer { + return new class extends mock<IWorkbenchMcpServer>() { + override readonly id = id; + }(); + } + + test('defers observation until enabled and unlocks only added customization identities', async () => { + const skills = observableValue<readonly IAICustomizationListItem[]>('skills', []); + const instructions = observableValue<readonly IAICustomizationListItem[]>('instructions', []); + const customizationsLoaded = new DeferredPromise<void>(); + const mcpLoaded = new DeferredPromise<void>(); + const mcpChanged = disposables.add(new Emitter<IWorkbenchMcpServer | undefined>()); + const activeSessionResource = observableValue('activeSessionResource', URI.parse('test://session/one')); + let activeSource = new class extends mock<IAICustomizationItemSource>() { }(); + let servers: IWorkbenchMcpServer[] = []; + let getItemsCalls = 0; + let queryLocalCalls = 0; + const unlockedAchievements = observableValue<readonly ChatPetAchievementId[]>('unlockedAchievements', []); + const unlocked: ChatPetAchievementId[] = []; + const itemsModel = new class extends mock<IAICustomizationItemsModel>() { + override getItems(section: ItemsModelSection) { + getItemsCalls++; + return section === AICustomizationManagementSection.Skills ? skills : instructions; + } + override getActiveItemSource(): IAICustomizationItemSource { + return activeSource; + } + override whenSectionLoaded(): Promise<void> { + return customizationsLoaded.p; + } + }(); + const customizationHarnessService = new class extends mock<ICustomizationHarnessService>() { + override readonly activeSessionResource = activeSessionResource; + override readonly availableHarnesses = constObservable([]); + }(); + const mcpWorkbenchService = new class extends mock<IMcpWorkbenchService>() { + override readonly onChange = mcpChanged.event; + override readonly onReset = Event.None; + override get local(): readonly IWorkbenchMcpServer[] { + return servers; + } + override async queryLocal(): Promise<IWorkbenchMcpServer[]> { + queryLocalCalls++; + await mcpLoaded.p; + return servers; + } + }(); + const enabled = observableValue('enabled', false); + const chatPetService = new class extends mock<IChatPetService>() { + override readonly enabled = enabled; + override readonly unlockedAchievements = unlockedAchievements; + override unlockAchievement(id: ChatPetAchievementId): boolean { + unlocked.push(id); + unlockedAchievements.set([...unlocked], undefined); + return true; + } + }(); + const logService = new class extends mock<ILogService>() { }(); + disposables.add(new ChatPetCustomizationAchievementContribution( + chatPetService, + itemsModel, + customizationHarnessService, + mcpWorkbenchService, + logService, + )); + + skills.set([customization('existing-skill', PromptsType.skill)], undefined); + instructions.set([customization('existing-instructions', PromptsType.instructions)], undefined); + servers = [mcpServer('existing-server')]; + const callsBeforeEnablement = { getItemsCalls, queryLocalCalls }; + enabled.set(true, undefined); + customizationsLoaded.complete(); + mcpLoaded.complete(); + await timeout(0); + const startupUnlocks = [...unlocked]; + + activeSource = new class extends mock<IAICustomizationItemSource>() { }(); + activeSessionResource.set(URI.parse('test://session/two'), undefined); + skills.set([customization('other-existing-skill', PromptsType.skill)], undefined); + instructions.set([customization('other-existing-instructions', PromptsType.instructions)], undefined); + await timeout(0); + const sourceSwitchUnlocks = [...unlocked]; + + mcpChanged.fire(servers[0]); + const enablementChangeUnlocks = [...unlocked]; + servers = [servers[0], mcpServer('new-disabled-server')]; + mcpChanged.fire(servers[1]); + skills.set([ + customization('other-existing-skill', PromptsType.skill), + customization('new-skill', PromptsType.skill), + ], undefined); + instructions.set([ + customization('other-existing-instructions', PromptsType.instructions), + customization('new-instructions', PromptsType.instructions), + ], undefined); + + assert.deepStrictEqual({ + callsBeforeEnablement, + callsAfterEnablement: { getItemsCalls, queryLocalCalls }, + startupUnlocks, + sourceSwitchUnlocks, + enablementChangeUnlocks, + unlocked, + }, { + callsBeforeEnablement: { getItemsCalls: 0, queryLocalCalls: 0 }, + callsAfterEnablement: { getItemsCalls: 2, queryLocalCalls: 1 }, + startupUnlocks: [], + sourceSwitchUnlocks: [], + enablementChangeUnlocks: [], + unlocked: [ + ChatPetAchievementIds.McpServerPresent, + ChatPetAchievementIds.CustomSkillPresent, + ChatPetAchievementIds.InstructionPresent, + ], + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts new file mode 100644 index 00000000000000..8cfefac4336a41 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable } 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 { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { EditorInputCapabilities } from '../../../../common/editor.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.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 { ChatPetAchievementsWidget } from '../../browser/chatPetAchievementsWidget.js'; +import { ChatPetVariant, IChatPetService } from '../../browser/chatPetService.js'; + +suite('Chat Pet Achievements Editor', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('opens a standalone modal editor input', async () => { + let openedInput: ChatPetAchievementsEditorInput | undefined; + let pinned: boolean | undefined; + const editorService = { + openEditor: async (input: ChatPetAchievementsEditorInput, options: { readonly pinned?: boolean }) => { + openedInput = input; + pinned = options.pinned; + return undefined; + }, + }; + const chatPetService = new class extends mock<IChatPetService>() { + override readonly enabled = constObservable(true); + }(); + const accessor = { + get: (service: typeof IEditorService | typeof IChatPetService) => { + if (service === IChatPetService) { + return chatPetService; + } + assert.strictEqual(service, IEditorService); + return editorService; + }, + } as ServicesAccessor; + const command = CommandsRegistry.getCommand(CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID); + assert.ok(command); + + await command.handler(accessor); + assert.ok(openedInput); + store.add(openedInput); + assert.deepStrictEqual({ + name: openedInput.getName(), + pinned, + singleton: openedInput.hasCapability(EditorInputCapabilities.Singleton), + requiresModal: openedInput.hasCapability(EditorInputCapabilities.RequiresModal), + modalOptions: openedInput.getModalEditorOptions(), + }, { + name: 'Achievements', + pinned: true, + singleton: true, + requiresModal: true, + modalOptions: { compactHeader: true }, + }); + }); + + test('does not open while the pet is disabled', async () => { + let openCount = 0; + const accessor = { + get: (service: typeof IEditorService | typeof IChatPetService) => service === IChatPetService + ? new class extends mock<IChatPetService>() { override readonly enabled = constObservable(false); }() + : new class extends mock<IEditorService>() { + override async openEditor(): Promise<undefined> { + openCount++; + return undefined; + } + }(), + } as ServicesAccessor; + const command = CommandsRegistry.getCommand(CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID); + assert.ok(command); + + await command.handler(accessor); + + assert.strictEqual(openCount, 0); + }); + + test('requests modal close when Escape is pressed on a selectable card', () => { + const parent = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(parent); + store.add(toDisposable(() => parent.remove())); + let closeCount = 0; + const chatPetService = new class extends mock<IChatPetService>() { + override readonly enabled = constObservable(true); + override readonly unlockedAchievements = constObservable<readonly ChatPetAchievementId[]>([ChatPetAchievementIds.FirstChatMessage]); + override readonly unseenAchievements = constObservable<readonly ChatPetAchievementId[]>([]); + override readonly selectedAccessory = constObservable<ChatPetAccessoryId | undefined>(undefined); + override readonly variant = constObservable<ChatPetVariant>('stable'); + }(); + const widget = store.add(new ChatPetAchievementsWidget( + parent, + () => closeCount++, + chatPetService, + new TestThemeService(), + store.add(new NullLogService()), + )); + + const noHatCard = parent.querySelector<HTMLElement>('[data-accessory-id="none"]'); + const cowboyCard = parent.querySelector<HTMLElement>(`[data-accessory-id="${ChatPetAccessoryIds.CowboyHat}"]`); + assert.ok(noHatCard); + assert.ok(cowboyCard); + noHatCard.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + cowboyCard.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + widget.dispose(); + + assert.strictEqual(closeCount, 2); + }); +}); 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 bd8e65f607f1c1..49182a0d66580c 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 @@ -5,11 +5,25 @@ import assert from 'assert'; import sinon from 'sinon'; +import { mainWindow } from '../../../../../../base/browser/window.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { constObservable } from '../../../../../../base/common/observable.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.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 { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { IHostService } from '../../../../../services/host/browser/host.js'; +import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, chatPetAchievements, ChatPetAccessoryIds, ChatPetAchievementIds, disabledChatPetAchievements, getChatPetAchievement, getChatPetAchievementPresentation, getChatPetCustomizationAchievementIds, getUnlockedChatPetAccessories, isUserAuthoredChatPetCustomization, shouldUnlockChatPetIntegratedBrowserShare } from '../../../browser/chatPetAchievements.js'; import { ChatPetService, getChatPetVariant } from '../../../browser/chatPetService.js'; -import { CHAT_PET_CONFIRMATION_ATTENTION_DURATION, CHAT_PET_ICON_TRANSFORMATION_CHANCE, CHAT_PET_IDLE_SLEEP_DELAY, CHAT_PET_WALL_IMPACT_DURATION, CHAT_PET_YAPPING_CHANCE, ChatPetBlinkController, ChatPetDirectionChangeController, ChatPetFacingController, ChatPetHopController, advanceChatPetThrow, doesChatPetStateBlink, doesChatPetStateTrackCursor, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBlinkDelay, getChatPetBuddyName, getChatPetClickInteraction, getChatPetDefaultHorizontalPosition, getChatPetDragPosition, getChatPetFallDuration, getChatPetFallTarget, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalPosition, getChatPetPlatformTop, getChatPetRelativeHorizontalPosition, getChatPetRenderedState, getChatPetRespawnFrameDurations, getChatPetRestoredHorizontalPosition, getChatPetScale, getChatPetSpeechFrameDurations, getChatPetSpriteName, getChatPetThrowLanding, getChatPetThrowRotation, getChatPetThrowVelocity, getChatPetVerticalOffset, getChatPetWallReboundVelocity, getChatPetWideSpriteHorizontalOffset, isChatPetImageSource, isChatPetKeyboardInteractionEnabled, isChatPetVisible, isChatPetWindowActive, shouldPlaceChatPetSpeechBubbleLeft, shouldReserveChatPetSpace, shouldSettleChatPetThrow } from '../../../browser/widget/chatPetWidget.js'; +import { getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from '../../../browser/widget/chatPetAccessoryRenderer.js'; +import { getChatPetAccessoryRigFrame, getChatPetAccessoryRigPose, getChatPetAccessoryTrack, getChatPetAntennaeOcclusionBounds, getChatPetEyeAccessoryAnchor, getChatPetReducedMotionRigFrame } from '../../../browser/widget/chatPetAccessoryRig.js'; +import { CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION, CHAT_PET_CONFIRMATION_ATTENTION_DURATION, CHAT_PET_ICON_TRANSFORMATION_CHANCE, CHAT_PET_IDLE_SLEEP_DELAY, CHAT_PET_WALL_IMPACT_DURATION, CHAT_PET_YAPPING_CHANCE, ChatPetBlinkController, ChatPetDirectionChangeController, ChatPetFacingController, ChatPetHopController, ChatPetWidget, advanceChatPetThrow, doesChatPetStateBlink, doesChatPetStateTrackCursor, drawChatPetAchievementStar, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBlinkDelay, getChatPetBuddyName, getChatPetClickInteraction, getChatPetDefaultHorizontalPosition, getChatPetDragPosition, getChatPetEyeAccessoryGazeOffset, getChatPetFallDuration, getChatPetFallTarget, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalPosition, getChatPetPlatformTop, getChatPetRelativeHorizontalPosition, getChatPetRenderedState, getChatPetRespawnFrameDurations, getChatPetRestoredHorizontalPosition, getChatPetScale, getChatPetSpeechFrameDurations, getChatPetSpriteName, getChatPetThrowLanding, getChatPetThrowRotation, getChatPetThrowVelocity, getChatPetVerticalOffset, getChatPetWallReboundVelocity, getChatPetWideSpriteHorizontalOffset, isChatPetImageSource, isChatPetKeyboardInteractionEnabled, isChatPetVisible, isChatPetWindowActive, setChatPetWideLayerOffset, shouldPlaceChatPetSpeechBubbleLeft, shouldReserveChatPetSpace, shouldSettleChatPetThrow } from '../../../browser/widget/chatPetWidget.js'; suite('ChatPetWidget', () => { @@ -65,6 +79,50 @@ suite('ChatPetWidget', () => { } }); + test('constructs body, pupils, and eye accessory layers in rendering order', () => { + const parent = mainWindow.document.createElement('div'); + const dragBounds = mainWindow.document.createElement('div'); + const movementBounds = mainWindow.document.createElement('div'); + mainWindow.document.body.append(parent, dragBounds, movementBounds); + disposables.add(toDisposable(() => { + parent.remove(); + dragBounds.remove(); + movementBounds.remove(); + })); + const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); + disposables.add(new ChatPetWidget( + parent, + dragBounds, + movementBounds, + constObservable(undefined), + constObservable(false), + constObservable(true), + Event.None, + service, + new TestAccessibilityService(), + new class extends mock<IContextMenuService>() { }(), + new class extends mock<ICommandService>() { }(), + new NullLogService(), + new class extends mock<IHostService>() { + override readonly hasFocus = true; + override readonly onDidChangeFocus = Event.None; + override readonly onDidChangeActiveWindow = Event.None; + }(), + )); + const visual = parent.getElementsByClassName('chat-pet-visual')[0]; + const button = parent.getElementsByClassName('chat-pet-button')[0] as HTMLElement; + button.dataset.state = 'achievementUnlocked'; + const achievementPupil = visual.getElementsByClassName('chat-pet-pupil')[0] as HTMLElement; + + assert.deepStrictEqual({ + layers: Array.from(visual.children).map(child => child.className).slice(0, 4), + achievementPupilHeight: mainWindow.getComputedStyle(achievementPupil).height, + }, { + layers: ['chat-pet-sprite hidden', 'chat-pet-sprite hidden', 'chat-pet-eyes', 'chat-pet-eye-accessory hidden'], + achievementPupilHeight: '8px', + }); + }); + test('repeats hops while key requests remain within the hold grace period', () => { const clock = sinon.useFakeTimers(); const { controller, events } = createHopHarness(); @@ -321,6 +379,104 @@ suite('ChatPetWidget', () => { assert.strictEqual(CHAT_PET_IDLE_SLEEP_DELAY, 20_000); }); + test('shows achievement attention for ten seconds', () => { + assert.strictEqual(CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION, 10_000); + }); + + test('draws a centered gold star on the speech bubble effect grid', () => { + const canvas = mainWindow.document.createElement('canvas'); + canvas.width = 96; + canvas.height = 96; + const context = canvas.getContext('2d'); + assert.ok(context); + + drawChatPetAchievementStar(context, 'stable'); + + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const goldPixels: Array<readonly [number, number]> = []; + for (let y = 0; y < canvas.height; y++) { + for (let x = 0; x < canvas.width; x++) { + const index = (y * canvas.width + x) * 4; + if (imageData.data[index] === 255 && imageData.data[index + 1] === 205 && imageData.data[index + 2] === 15 && imageData.data[index + 3] === 255) { + goldPixels.push([x, y]); + } + } + } + const rows = Array.from({ length: 5 }, (_, y) => Array.from({ length: 5 }, (_, x) => { + const index = ((36 + y * 4) * canvas.width + 58 + x * 4) * 4; + return imageData.data[index] === 255 && imageData.data[index + 1] === 205 && imageData.data[index + 2] === 15 ? '#' : '.'; + }).join('')); + assert.deepStrictEqual({ + count: goldPixels.length, + rows, + bounds: [ + Math.min(...goldPixels.map(([x]) => x)), + Math.min(...goldPixels.map(([, y]) => y)), + Math.max(...goldPixels.map(([x]) => x)), + Math.max(...goldPixels.map(([, y]) => y)), + ], + }, { + count: 208, + rows: ['..#..', '.###.', '#####', '.#.#.', '#...#'], + bounds: [58, 36, 77, 55], + }); + }); + + test('opens achievements when activated during the unlock state', () => { + const parent = mainWindow.document.createElement('div'); + const dragBounds = mainWindow.document.createElement('div'); + const movementBounds = mainWindow.document.createElement('div'); + mainWindow.document.body.append(parent, dragBounds, movementBounds); + const commands: string[] = []; + const storageService = disposables.add(new TestStorageService()); + const service = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); + const widget = disposables.add(new ChatPetWidget( + parent, + dragBounds, + movementBounds, + constObservable(undefined), + constObservable(false), + constObservable(true), + Event.None, + service, + new TestAccessibilityService(), + new class extends mock<IContextMenuService>() { }(), + new class extends mock<ICommandService>() { + override async executeCommand<R = unknown>(commandId: string): Promise<R | undefined> { + commands.push(commandId); + return undefined; + } + }(), + new NullLogService(), + new class extends mock<IHostService>() { + override readonly hasFocus = true; + override readonly onDidChangeFocus = Event.None; + override readonly onDidChangeActiveWindow = Event.None; + }(), + )); + disposables.add(toDisposable(() => { + parent.remove(); + dragBounds.remove(); + movementBounds.remove(); + })); + + service.toggle(); + service.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + const transientState = Reflect.get(widget, '_transientState'); + const stateBeforeActivation = transientState.get(); + (parent.querySelector('.chat-pet-button') as HTMLElement).click(); + + assert.deepStrictEqual({ + stateBeforeActivation, + stateAfterActivation: transientState.get(), + commands, + }, { + stateBeforeActivation: 'achievementUnlocked', + stateAfterActivation: undefined, + commands: [CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID], + }); + }); + test('selects the buddy for the product quality', () => { assert.deepStrictEqual([ getChatPetBuddyName('stable'), @@ -349,7 +505,7 @@ suite('ChatPetWidget', () => { test('logs pet enablement at startup and when toggled', () => { const telemetryService = new TestTelemetryService(); - const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), telemetryService)); + const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), telemetryService, new NullLogService())); service.toggle(); service.toggle(); @@ -363,13 +519,13 @@ suite('ChatPetWidget', () => { test('persists pet scale and position across windows, dismissal, and restart', () => { const storageService = disposables.add(new TestStorageService()); - const firstWindow = disposables.add(new ChatPetService(storageService, new TestTelemetryService())); - const secondWindow = disposables.add(new ChatPetService(storageService, new TestTelemetryService())); + const firstWindow = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); + const secondWindow = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); firstWindow.toggle(); firstWindow.setScale(1.4); firstWindow.setHorizontalPosition(0.3); const dismissed = firstWindow.toggle(); - const restartedWindow = disposables.add(new ChatPetService(storageService, new TestTelemetryService())); + const restartedWindow = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); assert.deepStrictEqual({ firstWindow: firstWindow.scale.get(), @@ -388,6 +544,379 @@ suite('ChatPetWidget', () => { }); }); + test('persists idempotent achievements and synchronizes the selected accessory', () => { + const storageService = disposables.add(new TestStorageService()); + const firstService = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); + const secondService = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); + const unlocks: string[] = []; + const synchronizedUnlocks: string[] = []; + disposables.add(firstService.onDidUnlockAchievement(id => unlocks.push(id))); + disposables.add(secondService.onDidUnlockAchievement(id => synchronizedUnlocks.push(id))); + + assert.strictEqual(firstService.unlockAchievement(ChatPetAchievementIds.FirstChatMessage), false); + firstService.toggle(); + assert.strictEqual(firstService.unlockAchievement(ChatPetAchievementIds.FirstChatMessage), true); + assert.strictEqual(firstService.unlockAchievement(ChatPetAchievementIds.FirstChatMessage), false); + firstService.setAccessory(ChatPetAccessoryIds.CowboyHat); + const unseenBeforeAcknowledgement = { + first: firstService.unseenAchievements.get(), + second: secondService.unseenAchievements.get(), + }; + const markedSeen = firstService.markAchievementSeen(ChatPetAchievementIds.FirstChatMessage); + + assert.deepStrictEqual({ + unlocks, + synchronizedUnlocks, + firstUnlocked: firstService.unlockedAchievements.get(), + secondUnlocked: secondService.unlockedAchievements.get(), + firstAccessory: firstService.selectedAccessory.get(), + secondAccessory: secondService.selectedAccessory.get(), + storedAchievement: storageService.getBoolean('chat.vscodePet.achievement.firstChatMessage', StorageScope.APPLICATION_SHARED), + storedAccessory: storageService.get('chat.vscodePet.accessory', StorageScope.APPLICATION_SHARED), + unseenBeforeAcknowledgement, + markedSeen, + firstUnseen: firstService.unseenAchievements.get(), + secondUnseen: secondService.unseenAchievements.get(), + }, { + unlocks: [ChatPetAchievementIds.FirstChatMessage], + synchronizedUnlocks: [ChatPetAchievementIds.FirstChatMessage], + firstUnlocked: [ChatPetAchievementIds.FirstChatMessage], + secondUnlocked: [ChatPetAchievementIds.FirstChatMessage], + firstAccessory: ChatPetAccessoryIds.CowboyHat, + secondAccessory: ChatPetAccessoryIds.CowboyHat, + storedAchievement: true, + storedAccessory: ChatPetAccessoryIds.CowboyHat, + unseenBeforeAcknowledgement: { + first: [ChatPetAchievementIds.FirstChatMessage], + second: [ChatPetAchievementIds.FirstChatMessage], + }, + markedSeen: true, + firstUnseen: [], + secondUnseen: [], + }); + }); + + test('starts fresh with no achievements and resets persisted developer state', () => { + const storageService = disposables.add(new TestStorageService()); + const service = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); + const freshUnlocked = service.unlockedAchievements.get(); + service.toggle(); + service.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + service.setAccessory(ChatPetAccessoryIds.CowboyHat); + service.setScale(1.4); + service.setHorizontalPosition(0.3); + storageService.store('chat.vscodePet.achievement.chatFork', true, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + storageService.store('chat.vscodePet.achievement.chatFork', true, StorageScope.APPLICATION, StorageTarget.USER); + const disabledUnlock = service.unlockAchievement(ChatPetAchievementIds.InstructionPresent); + service.resetAchievements(); + storageService.store('chat.vscodePet.achievementCatalogVersion', 3, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + const migratedService = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); + + assert.deepStrictEqual({ + freshUnlocked, + disabledUnlock, + unlocked: service.unlockedAchievements.get(), + unseen: service.unseenAchievements.get(), + accessory: service.selectedAccessory.get(), + scale: service.scale.get(), + horizontalPosition: service.horizontalPosition.get(), + storedFirstMessage: storageService.getBoolean('chat.vscodePet.achievement.firstChatMessage', StorageScope.APPLICATION_SHARED, false), + storedDisabled: storageService.getBoolean('chat.vscodePet.achievement.modelSwitch', StorageScope.APPLICATION_SHARED, false), + storedChatForkShared: storageService.getBoolean('chat.vscodePet.achievement.chatFork', StorageScope.APPLICATION_SHARED, false), + storedChatForkLocal: storageService.getBoolean('chat.vscodePet.achievement.chatFork', StorageScope.APPLICATION, false), + migratedUnlocks: migratedService.unlockedAchievements.get(), + }, { + freshUnlocked: [], + disabledUnlock: false, + unlocked: [], + unseen: [], + accessory: undefined, + scale: 1.4, + horizontalPosition: 0.3, + storedFirstMessage: false, + storedDisabled: false, + storedChatForkShared: false, + storedChatForkLocal: false, + migratedUnlocks: [], + }); + }); + + test('preserves achievements while disabled and rejects locked or malformed accessories', () => { + const storageService = disposables.add(new TestStorageService()); + storageService.store('chat.vscodePet.accessory', 'unknown', StorageScope.APPLICATION_SHARED, StorageTarget.USER); + const service = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); + + assert.throws(() => service.setAccessory(ChatPetAccessoryIds.PartyHat), /disabled/); + service.toggle(); + service.unlockAchievement(ChatPetAchievementIds.RequestRevision); + service.setAccessory(ChatPetAccessoryIds.TopHatMonocle); + service.toggle(); + + assert.deepStrictEqual({ + enabled: service.enabled.get(), + unlocked: service.unlockedAchievements.get(), + accessory: service.selectedAccessory.get(), + }, { + enabled: false, + unlocked: [ChatPetAchievementIds.RequestRevision], + accessory: ChatPetAccessoryIds.TopHatMonocle, + }); + }); + + test('migrates legacy achievement rewards and selected accessories', () => { + const storageService = disposables.add(new TestStorageService()); + storageService.store('chat.vscodePet.achievement.checkpointRestore', true, StorageScope.APPLICATION, StorageTarget.USER); + storageService.store('chat.vscodePet.achievement.requestRevision', true, StorageScope.APPLICATION, StorageTarget.USER); + storageService.store('chat.vscodePet.achievement.modelSwitch', true, StorageScope.APPLICATION, StorageTarget.USER); + storageService.store('chat.vscodePet.accessory', ChatPetAccessoryIds.BaseballCap, StorageScope.APPLICATION, StorageTarget.USER); + + const service = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); + + assert.deepStrictEqual({ + unlocked: service.unlockedAchievements.get(), + accessory: service.selectedAccessory.get(), + sharedRequestRevision: storageService.getBoolean('chat.vscodePet.achievement.requestRevision', StorageScope.APPLICATION_SHARED), + sharedModelSwitch: storageService.getBoolean('chat.vscodePet.achievement.modelSwitch', StorageScope.APPLICATION_SHARED), + sharedAccessory: storageService.get('chat.vscodePet.accessory', StorageScope.APPLICATION_SHARED), + }, { + unlocked: [ + ChatPetAchievementIds.RequestRevision, + ChatPetAchievementIds.FirstChatMessage, + ChatPetAchievementIds.ModelSwitch, + ], + accessory: undefined, + sharedRequestRevision: true, + sharedModelSwitch: true, + sharedAccessory: ChatPetAccessoryIds.BaseballCap, + }); + }); + + test('migrates app-local achievements after another app advanced the shared catalog', () => { + const storageService = disposables.add(new TestStorageService()); + storageService.store('chat.vscodePet.achievementCatalogVersion', 4, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + storageService.store('chat.vscodePet.achievement.requestRevision', true, StorageScope.APPLICATION, StorageTarget.USER); + storageService.store('chat.vscodePet.achievement.modelSwitch', true, StorageScope.APPLICATION, StorageTarget.USER); + storageService.store('chat.vscodePet.accessory', ChatPetAccessoryIds.PartyHat, StorageScope.APPLICATION, StorageTarget.USER); + + const service = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); + + assert.deepStrictEqual({ + unlocked: service.unlockedAchievements.get(), + accessory: service.selectedAccessory.get(), + sharedRequestRevision: storageService.getBoolean('chat.vscodePet.achievement.requestRevision', StorageScope.APPLICATION_SHARED), + sharedModelSwitch: storageService.getBoolean('chat.vscodePet.achievement.modelSwitch', StorageScope.APPLICATION_SHARED), + sharedChatOutputCopied: storageService.getBoolean('chat.vscodePet.achievement.chatOutputCopied', StorageScope.APPLICATION_SHARED), + sharedQueueOrSteeringMessage: storageService.getBoolean('chat.vscodePet.achievement.queueOrSteeringMessage', StorageScope.APPLICATION_SHARED), + sharedAccessory: storageService.get('chat.vscodePet.accessory', StorageScope.APPLICATION_SHARED), + }, { + unlocked: [ + ChatPetAchievementIds.RequestRevision, + ChatPetAchievementIds.ModelSwitch, + ], + accessory: undefined, + sharedRequestRevision: true, + sharedModelSwitch: true, + sharedChatOutputCopied: true, + sharedQueueOrSteeringMessage: true, + sharedAccessory: ChatPetAccessoryIds.PartyHat, + }); + }); + + test('replays version 2 reward mappings from shared achievement state', () => { + const storageService = disposables.add(new TestStorageService()); + storageService.store('chat.vscodePet.achievementCatalogVersion', 2, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + storageService.store('chat.vscodePet.achievement.requestRevision', true, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + storageService.store('chat.vscodePet.achievement.modelSwitch', true, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + + const service = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); + + assert.deepStrictEqual({ + unlocked: service.unlockedAchievements.get(), + catalogVersion: storageService.getNumber('chat.vscodePet.achievementCatalogVersion', StorageScope.APPLICATION_SHARED), + }, { + unlocked: [ + ChatPetAchievementIds.RequestRevision, + ChatPetAchievementIds.ModelSwitch, + ], + catalogVersion: 4, + }); + }); + + test('preserves the Cowboy Hat from the former fork achievement', () => { + const storageService = disposables.add(new TestStorageService()); + storageService.store('chat.vscodePet.achievementCatalogVersion', 3, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + storageService.store('chat.vscodePet.achievement.chatFork', true, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + storageService.store('chat.vscodePet.accessory', ChatPetAccessoryIds.CowboyHat, StorageScope.APPLICATION_SHARED, StorageTarget.USER); + + const service = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); + + assert.deepStrictEqual({ + unlocked: service.unlockedAchievements.get(), + accessory: service.selectedAccessory.get(), + catalogVersion: storageService.getNumber('chat.vscodePet.achievementCatalogVersion', StorageScope.APPLICATION_SHARED), + }, { + unlocked: [ChatPetAchievementIds.FirstChatMessage], + accessory: ChatPetAccessoryIds.CowboyHat, + catalogVersion: 4, + }); + }); + + test('detects user-authored customizations', () => { + assert.deepStrictEqual([ + isUserAuthoredChatPetCustomization('local', false), + isUserAuthoredChatPetCustomization('user', undefined), + isUserAuthoredChatPetCustomization('extension', false), + isUserAuthoredChatPetCustomization('plugin', false), + isUserAuthoredChatPetCustomization('builtin', true), + isUserAuthoredChatPetCustomization('local', true), + ], [true, true, false, false, false, false]); + }); + + test('unlocks browser sharing only after sharing succeeds', () => { + assert.deepStrictEqual([ + shouldUnlockChatPetIntegratedBrowserShare(false, false), + shouldUnlockChatPetIntegratedBrowserShare(false, true), + shouldUnlockChatPetIntegratedBrowserShare(true, false), + shouldUnlockChatPetIntegratedBrowserShare(true, true), + ], [false, false, false, true]); + }); + + test('finds customization achievements from user-authored items and MCP servers', () => { + assert.deepStrictEqual([ + getChatPetCustomizationAchievementIds([], [], 0), + getChatPetCustomizationAchievementIds([{ source: 'extension' }, { source: 'builtin', isBuiltin: true }], [{ source: 'plugin' }], 0), + getChatPetCustomizationAchievementIds([{ source: 'local' }], [{ source: 'user' }], 1), + ], [ + [], + [], + [ + ChatPetAchievementIds.CustomSkillPresent, + ChatPetAchievementIds.InstructionPresent, + ChatPetAchievementIds.McpServerPresent, + ], + ]); + }); + + test('defines one unique covered-antennae reward for each achievement', () => { + assert.deepStrictEqual({ + count: chatPetAchievements.length, + achievementIds: chatPetAchievements.map(achievement => achievement.id), + accessoryIds: chatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.id)), + atlasNames: chatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.atlasName)), + atlasCellSizes: chatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.atlasCellSize ?? 64)), + rewardCounts: chatPetAchievements.map(achievement => achievement.accessories.length), + coversAntennae: chatPetAchievements.every(achievement => achievement.accessories.every(accessory => accessory.coversAntennae)), + crownAccessoryId: ChatPetAccessoryIds.Crown, + disabledAchievementIds: disabledChatPetAchievements.map(achievement => achievement.id), + disabledAccessoryIds: disabledChatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.id)), + }, { + count: 6, + achievementIds: [ + ChatPetAchievementIds.RequestRevision, + ChatPetAchievementIds.FirstChatMessage, + ChatPetAchievementIds.IntegratedBrowserShared, + ChatPetAchievementIds.ModelSwitch, + ChatPetAchievementIds.McpServerPresent, + ChatPetAchievementIds.CustomSkillPresent, + ], + accessoryIds: [ + ChatPetAccessoryIds.TopHatMonocle, + ChatPetAccessoryIds.CowboyHat, + ChatPetAccessoryIds.BaseballCap, + ChatPetAccessoryIds.ConstructionHardHat, + ChatPetAccessoryIds.FirefighterHelmet, + ChatPetAccessoryIds.Crown, + ], + atlasNames: [ + 'grand-top-hat-monocle', + 'cowboy-hat', + 'baseball-cap', + 'construction-hard-hat', + 'firefighter-helmet', + 'crown', + ], + atlasCellSizes: Array(6).fill(96), + rewardCounts: Array(6).fill(1), + coversAntennae: true, + crownAccessoryId: 'crown', + disabledAchievementIds: [ + ChatPetAchievementIds.InstructionPresent, + ChatPetAchievementIds.QueueOrSteeringMessage, + ChatPetAchievementIds.AgentsWindowOpened, + ChatPetAchievementIds.ChatOutputCopied, + ChatPetAchievementIds.ImageRequest, + ], + disabledAccessoryIds: [ + ChatPetAccessoryIds.SailorHat, + ChatPetAccessoryIds.SpinnerHat, + ChatPetAccessoryIds.VikingHelmet, + ChatPetAccessoryIds.PartyHat, + ChatPetAccessoryIds.ArtistBeret, + ], + }); + }); + + test('rewards model changes with the hard hat and custom skills with the crown', () => { + const modelSwitch = getChatPetAchievement(ChatPetAchievementIds.ModelSwitch); + const customSkill = getChatPetAchievement(ChatPetAchievementIds.CustomSkillPresent); + + assert.deepStrictEqual({ + modelSwitch: { + title: modelSwitch.title, + description: modelSwitch.description, + accessoryId: modelSwitch.accessories[0].id, + }, + customSkill: { + title: customSkill.title, + description: customSkill.description, + accessoryId: customSkill.accessories[0].id, + }, + }, { + modelSwitch: { + title: 'Model Citizen', + description: 'You selected a different model from the model picker.', + accessoryId: ChatPetAccessoryIds.ConstructionHardHat, + }, + customSkill: { + title: 'Skilled Builder', + description: 'You added a custom skill.', + accessoryId: ChatPetAccessoryIds.Crown, + }, + }); + }); + + test('does not expose secret achievement copy or locked rewards in presentation data', () => { + const lockedPresentation = getChatPetAchievementPresentation(chatPetAchievements[0], false); + const unlockedAccessories = getUnlockedChatPetAccessories([ChatPetAchievementIds.RequestRevision]); + const allUnlockedAccessories = getUnlockedChatPetAccessories(chatPetAchievements.map(achievement => achievement.id)); + + assert.deepStrictEqual({ + lockedPresentation, + lockedSerializationContainsTitle: JSON.stringify(lockedPresentation).includes(chatPetAchievements[0].title), + lockedSerializationContainsReward: chatPetAchievements[0].accessories.some(accessory => JSON.stringify(lockedPresentation).includes(accessory.label)), + unlockedAccessoryIds: unlockedAccessories.map(accessory => accessory.id), + allUnlockedAccessoryIds: allUnlockedAccessories.map(accessory => accessory.id), + }, { + lockedPresentation: { + locked: true, + id: ChatPetAchievementIds.RequestRevision, + }, + lockedSerializationContainsTitle: false, + lockedSerializationContainsReward: false, + unlockedAccessoryIds: [ + ChatPetAccessoryIds.TopHatMonocle, + ], + allUnlockedAccessoryIds: [ + ChatPetAccessoryIds.TopHatMonocle, + ChatPetAccessoryIds.CowboyHat, + ChatPetAccessoryIds.BaseballCap, + ChatPetAccessoryIds.ConstructionHardHat, + ChatPetAccessoryIds.FirefighterHelmet, + ChatPetAccessoryIds.Crown, + ], + }); + }); + test('cycles through click interactions without repeating and reserves one percent each for icon and yapping', () => { const interactionInterval = 0.98 / 6; assert.strictEqual(CHAT_PET_ICON_TRANSFORMATION_CHANCE, 1 / 100); @@ -612,6 +1141,7 @@ suite('ChatPetWidget', () => { getChatPetSpriteName('waking', 'stable'), getChatPetSpriteName('typing', 'insider'), getChatPetSpriteName('rendering', 'stable'), + getChatPetSpriteName('achievementUnlocked', 'stable'), getChatPetSpriteName('cool', 'stable'), getChatPetSpriteName('searching', 'stable'), getChatPetSpriteName('yappingMouthOpen', 'insider'), @@ -636,6 +1166,7 @@ suite('ChatPetWidget', () => { 'buddy-waking-stable', 'buddy-typing-insiders', 'buddy-rendering-stable', + 'buddy-rendering-stable', 'buddy-cool-stable', 'buddy-search-stable', 'buddy-yapping-insiders', @@ -656,6 +1187,188 @@ suite('ChatPetWidget', () => { ]); }); + test('maps every runtime state to a body-owned accessory track', () => { + assert.deepStrictEqual([ + 'idle', 'sleep', 'waking', 'typing', 'rendering', 'achievementUnlocked', 'buttonPress', 'complete', 'love', 'clapping', 'jump', 'cool', 'yapping', 'yappingMouthOpen', 'sing', 'speechless', 'worry', 'dizzy', 'falling', 'wallImpact', 'splat', 'onTheRun', 'searching', 'searchingDown', + ].map(state => getChatPetAccessoryTrack(state as Parameters<typeof getChatPetAccessoryTrack>[0])), [ + 'idle', 'sleep', 'waking', 'typing', 'rendering', 'rendering', 'buttonPress', 'idle', 'love', 'clapping', 'jump', 'cool', 'idle', 'yapping', 'sing', 'speechless', 'worry', 'dizzy', 'falling', 'wallImpact', 'splat', 'search', 'search', 'search', + ]); + }); + + test('maps exceptional body geometry to canonical accessory rig poses and anchors', () => { + assert.deepStrictEqual({ + poses: [ + getChatPetAccessoryRigPose('idle'), + getChatPetAccessoryRigPose('sleep'), + getChatPetAccessoryRigPose('waking', 3), + getChatPetAccessoryRigPose('jump'), + getChatPetAccessoryRigPose('wallImpact'), + getChatPetAccessoryRigPose('splat', 0), + getChatPetAccessoryRigPose('splat', 3), + ], + idleBob: [getChatPetAccessoryRigFrame('idle', 19), getChatPetAccessoryRigFrame('idle', 20)], + bodyTranslations: [ + getChatPetAccessoryRigFrame('rendering', 19), + getChatPetAccessoryRigFrame('rendering', 20), + getChatPetAccessoryRigFrame('sleep', 2), + getChatPetAccessoryRigFrame('sleep', 3), + getChatPetAccessoryRigFrame('waking', 0), + getChatPetAccessoryRigFrame('waking', 3), + ], + jump: [getChatPetAccessoryRigFrame('jump', 1), getChatPetAccessoryRigFrame('jump', 4)], + sing: getChatPetAccessoryRigFrame('sing', 0), + worry: [getChatPetAccessoryRigFrame('worry', 0), getChatPetAccessoryRigFrame('worry', 1)], + splat: [getChatPetAccessoryRigFrame('splat', 0), getChatPetAccessoryRigFrame('splat', 3)], + eyeSlotAvailability: [ + getChatPetAccessoryRigFrame('sing', 0).rightEye !== undefined, + getChatPetAccessoryRigFrame('love', 0).rightEye !== undefined, + getChatPetAccessoryRigFrame('complete', 0).rightEye !== undefined, + getChatPetAccessoryRigFrame('cool', 0).rightEye !== undefined, + getChatPetAccessoryRigFrame('dizzy', 0).rightEye !== undefined, + getChatPetAccessoryRigFrame('wallImpact', 0).rightEye !== undefined, + getChatPetAccessoryRigFrame('splat', 0).rightEye !== undefined, + getChatPetAccessoryRigFrame('splat', 3).rightEye !== undefined, + ], + headSlotAvailability: [ + getChatPetAccessoryRigFrame('idle', 0).head !== undefined, + getChatPetAccessoryRigFrame('love', 0).head !== undefined, + getChatPetAccessoryRigFrame('complete', 0).head !== undefined, + getChatPetAccessoryRigFrame('dizzy', 0).head !== undefined, + getChatPetAccessoryRigFrame('wallImpact', 0).head !== undefined, + ], + antennaeOcclusionBounds: [ + getChatPetAntennaeOcclusionBounds('idle', 0), + getChatPetAntennaeOcclusionBounds('idle', 20), + getChatPetAntennaeOcclusionBounds('sleep', 4), + getChatPetAntennaeOcclusionBounds('jump', 1), + getChatPetAntennaeOcclusionBounds('splat', 0), + getChatPetAntennaeOcclusionBounds('wallImpact', 0), + getChatPetAntennaeOcclusionBounds('love', 0), + ], + eyeFacingAnchors: [ + getChatPetEyeAccessoryAnchor('idle', 0, 'right', false), + getChatPetEyeAccessoryAnchor('idle', 0, 'left', false), + getChatPetEyeAccessoryAnchor('idle', 0, 'left', true), + getChatPetEyeAccessoryAnchor('sleep', 0, 'left', false, 120), + getChatPetEyeAccessoryAnchor('typing', 0, 'left', false, 168), + getChatPetEyeAccessoryAnchor('buttonPress', 0, 'left', false, 160), + getChatPetEyeAccessoryAnchor('sing', 0, 'left', false, 164), + ], + monocleMotion: { + breathing: [ + getChatPetEyeAccessoryAnchor('idle', 0, 'right', false), + getChatPetEyeAccessoryAnchor('idle', 20, 'right', false), + ], + gaze: [ + getChatPetEyeAccessoryGazeOffset([-1, -1]), + getChatPetEyeAccessoryGazeOffset([0, 0]), + getChatPetEyeAccessoryGazeOffset([1, 1]), + ], + }, + reducedMotionFrames: [ + getChatPetReducedMotionRigFrame('idle'), + getChatPetReducedMotionRigFrame('sleep'), + getChatPetReducedMotionRigFrame('waking'), + getChatPetReducedMotionRigFrame('buttonPress'), + getChatPetReducedMotionRigFrame('love'), + getChatPetReducedMotionRigFrame('splat'), + ], + }, { + poses: ['upright', 'sleeping', 'upright', 'airborne', 'impact', 'splat', 'upright'], + idleBob: [ + { pose: 'upright', head: { x: 48, y: 40 }, rightEye: { x: 56, y: 56 } }, + { pose: 'upright', head: { x: 48, y: 44 }, rightEye: { x: 56, y: 60 } }, + ], + bodyTranslations: [ + { pose: 'upright', head: { x: 48, y: 40 }, rightEye: { x: 56, y: 56 } }, + { pose: 'upright', head: { x: 48, y: 44 }, rightEye: { x: 56, y: 60 } }, + { pose: 'sleeping', head: { x: 48, y: 40 }, rightEye: { x: 56, y: 64 } }, + { pose: 'sleeping', head: { x: 48, y: 44 }, rightEye: { x: 56, y: 64 } }, + { pose: 'sleeping', head: { x: 48, y: 44 }, rightEye: { x: 56, y: 64 } }, + { pose: 'upright', head: { x: 48, y: 40 }, rightEye: { x: 56, y: 56 } }, + ], + jump: [ + { pose: 'airborne', head: { x: 48, y: 56 }, rightEye: { x: 56, y: 64 } }, + { pose: 'airborne', head: { x: 48, y: 64 }, rightEye: { x: 56, y: 64 } }, + ], + sing: { pose: 'upright', head: { x: 48, y: 60 }, rightEye: { x: 56, y: 72 } }, + worry: [ + { pose: 'upright', head: { x: 48, y: 40 }, rightEye: undefined }, + { pose: 'upright', head: { x: 48, y: 40 }, rightEye: undefined, mirrorsHeadAccessory: true }, + ], + splat: [ + { pose: 'splat', head: { x: 48, y: 80 }, rightEye: undefined }, + { pose: 'upright', head: { x: 48, y: 40 }, rightEye: { x: 56, y: 56 } }, + ], + eyeSlotAvailability: [true, false, false, false, false, false, false, true], + headSlotAvailability: [true, false, false, false, true], + antennaeOcclusionBounds: [ + { x: 16, y: -8, width: 64, height: 40 }, + { x: 16, y: -4, width: 64, height: 40 }, + { x: 16, y: -4, width: 64, height: 40 }, + { x: 16, y: 8, width: 64, height: 40 }, + { x: 16, y: 32, width: 64, height: 40 }, + { x: 16, y: 24, width: 64, height: 8 }, + undefined, + ], + eyeFacingAnchors: [ + { x: 56, y: 56 }, + { x: 40, y: 56 }, + { x: 56, y: 56 }, + { x: 64, y: 64 }, + { x: 112, y: 56 }, + { x: 104, y: 56 }, + { x: 108, y: 72 }, + ], + monocleMotion: { + breathing: [ + { x: 56, y: 56 }, + { x: 56, y: 60 }, + ], + gaze: [ + [-4, -4], + [0, 0], + [4, 4], + ], + }, + reducedMotionFrames: [0, 4, 7, 4, 5, 3], + }); + }); + + test('validates exact body and accessory atlas dimensions', () => { + const source = getChatPetAccessoryImageSource({ + id: ChatPetAccessoryIds.CowboyHat, + label: 'Cowboy Hat', + atlasName: 'cowboy-hat', + atlasCellSize: 96, + }); + const compactSource = getChatPetAccessoryImageSource({ + id: ChatPetAccessoryIds.TopHatMonocle, + label: 'Grand Top Hat & Monocle', + atlasName: 'grand-top-hat-monocle', + }); + + assert.deepStrictEqual({ + isAtlas: source.url.endsWith('/cowboy-hat.png'), + cellSize: source.cellSize, + compactCellSize: compactSource.cellSize, + bodyValid: hasChatPetBodyImageDimensions({ naturalWidth: 336, naturalHeight: 96 }, 168, 96, 2), + bodyWrongWidth: hasChatPetBodyImageDimensions({ naturalWidth: 168, naturalHeight: 96 }, 168, 96, 2), + wideAccessoryValid: hasChatPetAccessoryImageDimensions({ naturalWidth: 384, naturalHeight: 288 }, source), + compactAccessoryValid: hasChatPetAccessoryImageDimensions({ naturalWidth: 256, naturalHeight: 192 }, compactSource), + accessoryWrongSize: hasChatPetAccessoryImageDimensions({ naturalWidth: 256, naturalHeight: 192 }, source), + }, { + isAtlas: true, + cellSize: 96, + compactCellSize: 64, + bodyValid: true, + bodyWrongWidth: false, + wideAccessoryValid: true, + compactAccessoryValid: true, + accessoryWrongSize: false, + }); + }); + test('preserves the source animation timing', () => { assert.deepStrictEqual([ getChatPetFrameDurations('idle'), @@ -1041,4 +1754,23 @@ suite('ChatPetWidget', () => { ]); }); + test('applies wide sprite correction to body, eyes, and eye accessory together', () => { + const layers = [ + mainWindow.document.createElement('div'), + mainWindow.document.createElement('div'), + mainWindow.document.createElement('div'), + ]; + setChatPetWideLayerOffset(-36, layers); + const shifted = layers.map(layer => layer.style.translate); + setChatPetWideLayerOffset(0, layers); + + assert.deepStrictEqual({ + shifted, + reset: layers.map(layer => layer.style.translate), + }, { + shifted: ['-36px', '-36px', '-36px'], + reset: ['', '', ''], + }); + }); + }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts index d9af51dceb224e..9eea6e23ca89ad 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts @@ -13,8 +13,8 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { SaveReason } from '../../../../../common/editor.js'; import { ISaveAllEditorsOptions, ISaveEditorsResult } from '../../../../../services/editor/common/editorService.js'; import { TestEditorService } from '../../../../../test/browser/workbenchTestServices.js'; -import { acceptAndAwaitSentRequest, ChatWidget, getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight, saveAllBeforeChatSend, shouldShowChatTip, shouldShowChatWelcome } from '../../../browser/widget/chatWidget.js'; -import { ChatSendResult, ChatSendResultSent, IChatSendRequestData } from '../../../common/chatService/chatService.js'; +import { acceptAndAwaitSentRequest, ChatWidget, getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight, saveAllBeforeChatSend, shouldShowChatTip, shouldShowChatWelcome, shouldUnlockChatPetQueueOrSteeringMessage, shouldUnlockChatPetRequestRevision } from '../../../browser/widget/chatWidget.js'; +import { ChatRequestQueueKind, ChatSendResult, ChatSendResultSent, IChatSendRequestData } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../common/constants.js'; import { ChatRequestSlashCommandPart, ChatRequestTextPart, IParsedChatRequest } from '../../../common/requestParser/chatParserTypes.js'; import { observePromptTimelineHostWidth } from '../../../browser/promptTimeline/promptTimelineWidgetContrib.js'; @@ -143,6 +143,25 @@ suite('ChatWidget', () => { ], [true, false]); }); + test('only unlocks request revision for edited user submissions', () => { + assert.deepStrictEqual([ + shouldUnlockChatPetRequestRevision(false, false), + shouldUnlockChatPetRequestRevision(false, true), + shouldUnlockChatPetRequestRevision(true, false), + shouldUnlockChatPetRequestRevision(true, true), + ], [false, false, false, true]); + }); + + test('only unlocks queue or steering for queued user submissions', () => { + assert.deepStrictEqual([ + shouldUnlockChatPetQueueOrSteeringMessage(false, undefined), + shouldUnlockChatPetQueueOrSteeringMessage(true, undefined), + shouldUnlockChatPetQueueOrSteeringMessage(false, ChatRequestQueueKind.Queued), + shouldUnlockChatPetQueueOrSteeringMessage(true, ChatRequestQueueKind.Queued), + shouldUnlockChatPetQueueOrSteeringMessage(true, ChatRequestQueueKind.Steering), + ], [false, false, false, true, true]); + }); + test('identifies only leading silent execute-immediately slash commands', () => { const command = new ChatRequestSlashCommandPart( new OffsetRange(0, 7), diff --git a/src/vs/workbench/test/browser/aiCustomizationManagementSectionRegistry.test.ts b/src/vs/workbench/test/browser/aiCustomizationManagementSectionRegistry.test.ts index 1499b90cb1d4f6..a6ec066b216c82 100644 --- a/src/vs/workbench/test/browser/aiCustomizationManagementSectionRegistry.test.ts +++ b/src/vs/workbench/test/browser/aiCustomizationManagementSectionRegistry.test.ts @@ -37,4 +37,8 @@ suite('AI Customization Management Section Registry', () => { registrations.dispose(); }); + + test('keeps pet achievements outside AI Customizations', () => { + assert.strictEqual(new Set<string>(Object.values(AICustomizationManagementSection)).has('achievements'), false); + }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index c67ff4ba93413f..b1f48fd8502200 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -204,11 +204,19 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override readonly variant = observableValue('chatPetVariant', 'stable' as const); override readonly onTheRun = observableValue('chatPetOnTheRun', false); override readonly scale = observableValue('chatPetScale', 1); + override readonly unlockedAchievements = observableValue('chatPetUnlockedAchievements', []); + override readonly unseenAchievements = observableValue('chatPetUnseenAchievements', []); + override readonly selectedAccessory = observableValue('chatPetSelectedAccessory', undefined); + override readonly onDidUnlockAchievement = Event.None; override readonly horizontalPosition = observableValue<number | undefined>('chatPetHorizontalPosition', undefined); override toggle() { return false; } override setVariant() { } override setOnTheRun() { } override setScale(scale: number) { this.scale.set(scale, undefined); } + override unlockAchievement() { return false; } + override markAchievementSeen() { return false; } + override setAccessory() { } + override resetAchievements() { } override setHorizontalPosition(position: number) { this.horizontalPosition.set(position, undefined); } }()); reg.defineInstance(IChatWidgetService, new class extends mock<IChatWidgetService>() { diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts new file mode 100644 index 00000000000000..502f7030c8b338 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts @@ -0,0 +1,918 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { 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'; +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { configureChatPetFixtureFileRoot } from './chatPetFixtureUtils.js'; + +interface IRigPreview { + readonly label: string; + readonly state: ChatPetState; + readonly bodyName: string; + readonly frameWidth: number; + readonly frameHeight: number; + readonly frameCount: number; + readonly frameIndex: number; + readonly rigFrameIndex?: number; + readonly facing?: 'left' | 'right'; + readonly fixedOrientation?: boolean; + readonly rotation?: number; + readonly accessoryId?: ChatPetAccessoryId; +} + +const previews: readonly IRigPreview[] = [ + { label: 'Idle', state: 'idle', bodyName: 'buddy-idle-stable-tracking-96', frameWidth: 96, frameHeight: 96, frameCount: 50, frameIndex: 0 }, + { label: 'Idle bob', state: 'idle', bodyName: 'buddy-idle-stable-tracking-96', frameWidth: 96, frameHeight: 96, frameCount: 50, frameIndex: 20 }, + { label: 'Sleep', state: 'sleep', bodyName: 'buddy-sleep-stable-96', frameWidth: 120, frameHeight: 96, frameCount: 8, frameIndex: 0 }, + { label: 'Wake upright', state: 'waking', bodyName: 'buddy-waking-stable-96', frameWidth: 120, frameHeight: 96, frameCount: 8, frameIndex: 3 }, + { label: 'Jump rise', state: 'jump', bodyName: 'buddy-jump-stable-96', frameWidth: 96, frameHeight: 96, frameCount: 6, frameIndex: 1 }, + { label: 'Jump fall', state: 'jump', bodyName: 'buddy-jump-stable-96', frameWidth: 96, frameHeight: 96, frameCount: 6, frameIndex: 4 }, + { label: 'Dizzy', state: 'dizzy', bodyName: 'buddy-dizzy-stable-128', frameWidth: 96, frameHeight: 128, frameCount: 8, frameIndex: 0 }, + { label: 'Sing right', state: 'sing', bodyName: 'buddy-sing-stable-124', frameWidth: 164, frameHeight: 124, frameCount: 4, frameIndex: 2, fixedOrientation: true }, + { label: 'Sing left', state: 'sing', bodyName: 'buddy-sing-stable-124', frameWidth: 164, frameHeight: 124, frameCount: 4, frameIndex: 2, facing: 'left', fixedOrientation: true }, + { label: 'Worry right', state: 'worry', bodyName: 'buddy-worry-stable-96', frameWidth: 96, frameHeight: 96, frameCount: 2, frameIndex: 0, accessoryId: ChatPetAccessoryIds.BaseballCap }, + { label: 'Worry left', state: 'worry', bodyName: 'buddy-worry-stable-96', frameWidth: 96, frameHeight: 96, frameCount: 2, frameIndex: 1, accessoryId: ChatPetAccessoryIds.BaseballCap }, + { label: 'Rare icon side view', state: 'complete', bodyName: 'buddy-idle-stable-96', frameWidth: 96, frameHeight: 96, frameCount: 1, frameIndex: 0, rotation: 90, accessoryId: ChatPetAccessoryIds.BaseballCap }, + { label: 'Wall impact', state: 'wallImpact', bodyName: 'buddy-wall-impact-stable-96', frameWidth: 96, frameHeight: 96, frameCount: 1, frameIndex: 0 }, + { label: 'Splat impact', state: 'splat', bodyName: 'buddy-splat-stable-96', frameWidth: 96, frameHeight: 96, frameCount: 4, frameIndex: 0 }, + { label: 'Splat recovery', state: 'splat', bodyName: 'buddy-splat-stable-96', frameWidth: 96, frameHeight: 96, frameCount: 4, frameIndex: 3 }, + { label: 'Love (no accessory)', state: 'love', bodyName: 'buddy-love-stable-96', frameWidth: 96, frameHeight: 96, frameCount: 1, frameIndex: 0, rigFrameIndex: 5 }, + { label: 'Splat reduced motion', state: 'splat', bodyName: 'buddy-splat-stable-96', frameWidth: 96, frameHeight: 96, frameCount: 1, frameIndex: 0, rigFrameIndex: 3 }, +]; + +interface IChatPetProductionAccessoryPreview { + readonly accessoryId: ChatPetAccessoryId; + readonly shape: string; +} + +interface IChatPetFixtureFrame { + readonly state: ChatPetState; + readonly frameWidth: number; + readonly frameHeight: number; + readonly bodyFrameIndex: number; + readonly rigFrameIndex: number; +} + +const productionAccessoryPreviews: readonly IChatPetProductionAccessoryPreview[] = [ + { + accessoryId: ChatPetAccessoryIds.CowboyHat, + shape: 'Low rounded crown with a broad curved brim', + }, + { + accessoryId: ChatPetAccessoryIds.BaseballCap, + shape: 'Paneled red crown with a long side-facing bill', + }, + { + accessoryId: ChatPetAccessoryIds.TopHatMonocle, + shape: 'Extra-tall squared crown with a full-width brim', + }, + { + accessoryId: ChatPetAccessoryIds.PartyHat, + shape: 'Single sloped cone with a centered pom and broad band', + }, + { + accessoryId: ChatPetAccessoryIds.SailorHat, + shape: 'White Dixie-cup cap with a balanced crown and subtle forward brim', + }, + { + accessoryId: ChatPetAccessoryIds.SpinnerHat, + shape: 'Domed beanie with a wide multicolor propeller', + }, + { + accessoryId: ChatPetAccessoryIds.ConstructionHardHat, + shape: 'Low ribbed safety dome with a full-width brim', + }, + { + accessoryId: ChatPetAccessoryIds.FirefighterHelmet, + shape: 'Rounded red helmet with a gold shield and neck guard', + }, + { + accessoryId: ChatPetAccessoryIds.VikingHelmet, + shape: 'Balanced steel helmet with a longer forward horn and short nose guard', + }, + { + accessoryId: ChatPetAccessoryIds.Crown, + shape: 'Gold crown with tall points and jewel highlights', + }, + { + accessoryId: ChatPetAccessoryIds.ArtistBeret, + shape: 'Tilted berry beret with a raised stem and dark band', + }, +]; + +const allChatPetStates: readonly ChatPetState[] = [ + 'idle', + 'sleep', + 'waking', + 'typing', + 'rendering', + 'achievementUnlocked', + 'buttonPress', + 'complete', + 'love', + 'clapping', + 'jump', + 'cool', + 'yapping', + 'yappingMouthOpen', + 'sing', + 'speechless', + 'worry', + 'dizzy', + 'falling', + 'wallImpact', + 'splat', + 'onTheRun', + 'searching', + 'searchingDown', +]; + +export default defineThemedFixtureGroup({ path: 'chat/chatPetAccessoryRig/' }, { + CriticalPoses: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: renderCriticalPoses, + }), + AllRuntimeStates: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: renderAllRuntimeStates, + }), + AllAccessoriesFacing: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: renderAllAccessoriesFacing, + }), + CoveredAntennaeComparison: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: renderCoveredAntennaeComparison, + }), + LiveEyeLayering: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: renderLiveEyeLayering, + }), + AchievementUnlockStar: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderAchievementUnlockStar, + }), +}); + +async function renderCriticalPoses(ctx: ComponentFixtureContext): Promise<void> { + configureChatPetFixtureFileRoot(ctx.disposableStore); + + ctx.container.style.width = '1100px'; + ctx.container.style.height = '760px'; + ctx.container.style.boxSizing = 'border-box'; + ctx.container.style.padding = '24px'; + ctx.container.style.overflow = 'auto'; + ctx.container.style.background = 'var(--vscode-editor-background)'; + ctx.container.style.color = 'var(--vscode-foreground)'; + + const heading = DOM.append(ctx.container, DOM.$('h1')); + heading.textContent = 'Accessory rig critical poses'; + heading.style.margin = '0 0 8px'; + const description = DOM.append(ctx.container, DOM.$('p')); + description.textContent = 'One accessory atlas rendered through body-owned attachment tracks.'; + description.style.margin = '0 0 20px'; + description.style.color = 'var(--vscode-descriptionForeground)'; + + const grid = DOM.append(ctx.container, DOM.$('.chat-pet-accessory-rig-grid')); + grid.style.display = 'grid'; + grid.style.gridTemplateColumns = 'repeat(4, minmax(0, 1fr))'; + grid.style.gap = '16px'; + + const accessoryImages = new Map<ChatPetAccessoryId, HTMLImageElement>(); + await Promise.all([...new Set(previews.map(preview => preview.accessoryId ?? ChatPetAccessoryIds.TopHatMonocle))].map(async accessoryId => { + const accessory = getChatPetAccessory(accessoryId); + const source = getChatPetAccessoryImageSource(accessory); + const image = await loadImage(source.url); + if (!hasChatPetAccessoryImageDimensions(image, source)) { + throw new Error(`Invalid accessory atlas dimensions: ${source.url}`); + } + accessoryImages.set(accessoryId, image); + })); + + const bodyImages = await Promise.all(previews.map(async preview => { + const bodyUrl = FileAccess.asBrowserUri(`vs/workbench/contrib/chat/browser/widget/media/chatPet/${preview.bodyName}${preview.frameCount > 1 ? '.spritesheet' : ''}.png`).toString(true); + const bodyImage = await loadImage(bodyUrl); + if (!hasChatPetBodyImageDimensions(bodyImage, preview.frameWidth, preview.frameHeight, preview.frameCount)) { + throw new Error(`Invalid fixture body dimensions: ${bodyUrl}`); + } + return bodyImage; + })); + + for (let index = 0; index < previews.length; index++) { + const preview = previews[index]; + const bodyImage = bodyImages[index]; + const accessory = getChatPetAccessory(preview.accessoryId ?? ChatPetAccessoryIds.TopHatMonocle); + const accessoryImage = accessoryImages.get(accessory.id); + if (!accessoryImage) { + throw new Error(`Missing fixture accessory image: ${accessory.id}`); + } + const card = DOM.append(grid, DOM.$('.chat-pet-accessory-rig-card')); + card.style.minWidth = '0'; + card.style.padding = '12px'; + card.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border)'; + card.style.borderRadius = 'var(--vscode-cornerRadius-medium)'; + card.style.background = 'var(--vscode-editorWidget-background)'; + + const label = DOM.append(card, DOM.$('h2')); + label.textContent = preview.label; + label.style.margin = '0 0 8px'; + label.style.fontSize = 'var(--vscode-fontSize-heading3)'; + + const canvas = DOM.append(card, DOM.$('canvas')) as HTMLCanvasElement; + canvas.width = preview.frameWidth; + canvas.height = preview.frameHeight; + canvas.style.display = 'block'; + canvas.style.margin = '0 auto'; + canvas.style.width = `${preview.frameWidth / 2}px`; + canvas.style.height = `${preview.frameHeight / 2}px`; + canvas.style.imageRendering = 'pixelated'; + canvas.style.transform = preview.rotation ? `rotate(${preview.rotation}deg)` : ''; + canvas.setAttribute('aria-hidden', 'true'); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Canvas rendering context unavailable.'); + } + context.imageSmoothingEnabled = false; + drawChatPetComposite( + context, + bodyImage, + accessoryImage, + preview.frameIndex, + preview.rigFrameIndex ?? preview.frameIndex, + preview.frameWidth, + preview.frameHeight, + preview.facing ?? 'right', + preview.state, + preview.fixedOrientation ? CHAT_PET_SING_FIXED_ORIENTATION_DECORATIONS : undefined, + true, + accessory.eyeAccessoryMirrorsWithFacing !== false, + accessory.coversAntennae === true, + ); + } +} + +async function renderAllRuntimeStates(ctx: ComponentFixtureContext): Promise<void> { + configureChatPetFixtureFileRoot(ctx.disposableStore); + ctx.container.style.width = '1360px'; + ctx.container.style.height = '1000px'; + ctx.container.style.boxSizing = 'border-box'; + ctx.container.style.padding = '24px'; + ctx.container.style.overflow = 'auto'; + ctx.container.style.background = 'var(--vscode-editor-background)'; + ctx.container.style.color = 'var(--vscode-foreground)'; + + const heading = DOM.append(ctx.container, DOM.$('h1')); + heading.textContent = 'All animation states'; + heading.style.margin = '0 0 8px'; + const description = DOM.append(ctx.container, DOM.$('p')); + description.textContent = 'Every runtime state, composed with every accessory in both facing directions. Frames use the reduced-motion representative pose so the complete set can be compared at once.'; + description.style.margin = '0 0 20px'; + description.style.color = 'var(--vscode-descriptionForeground)'; + + const bodySources = allChatPetStates.map(getAllRuntimeStateBodySource); + const bodyImages = await Promise.all(bodySources.map(async source => { + const image = await loadImage(source.url); + if (!hasChatPetBodyImageDimensions(image, source.frameWidth, source.frameHeight, source.frameCount)) { + throw new Error(`Invalid all-state fixture body dimensions: ${source.url}`); + } + return image; + })); + const atlasImages = await Promise.all(chatPetAccessories.map(async accessory => { + const source = getChatPetAccessoryImageSource(accessory); + const image = await loadImage(source.url); + if (!hasChatPetAccessoryImageDimensions(image, source)) { + throw new Error(`Invalid accessory atlas dimensions: ${source.url}`); + } + return image; + })); + + const gallery = DOM.append(ctx.container, DOM.$('.chat-pet-all-runtime-states')); + gallery.style.display = 'grid'; + gallery.style.gridTemplateColumns = `180px repeat(${chatPetAccessories.length}, minmax(150px, 1fr))`; + gallery.style.gap = '8px'; + gallery.style.alignItems = 'start'; + + const stateHeader = DOM.append(gallery, DOM.$('.chat-pet-all-runtime-state-header')); + stateHeader.textContent = 'State'; + stateHeader.style.position = 'sticky'; + stateHeader.style.top = '0'; + stateHeader.style.zIndex = '1'; + stateHeader.style.padding = '10px'; + stateHeader.style.background = 'var(--vscode-editorWidget-background)'; + stateHeader.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border)'; + for (const accessory of chatPetAccessories) { + const accessoryHeader = DOM.append(gallery, DOM.$('.chat-pet-all-runtime-accessory-header')); + accessoryHeader.textContent = accessory.label; + accessoryHeader.style.position = 'sticky'; + accessoryHeader.style.top = '0'; + accessoryHeader.style.zIndex = '1'; + accessoryHeader.style.padding = '10px'; + accessoryHeader.style.background = 'var(--vscode-editorWidget-background)'; + accessoryHeader.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border)'; + accessoryHeader.style.fontWeight = '600'; + } + + for (let stateIndex = 0; stateIndex < allChatPetStates.length; stateIndex++) { + const state = allChatPetStates[stateIndex]; + const source = bodySources[stateIndex]; + const bodyImage = bodyImages[stateIndex]; + const stateLabel = DOM.append(gallery, DOM.$('.chat-pet-all-runtime-state-label')); + stateLabel.textContent = `${getChatPetStateLabel(state)} (frame ${source.frameIndex + 1}/${source.frameCount})`; + stateLabel.style.position = 'sticky'; + stateLabel.style.left = '0'; + stateLabel.style.padding = '10px'; + stateLabel.style.background = 'var(--vscode-editor-background)'; + stateLabel.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border)'; + stateLabel.style.fontWeight = '600'; + + for (let accessoryIndex = 0; accessoryIndex < chatPetAccessories.length; accessoryIndex++) { + const accessory = chatPetAccessories[accessoryIndex]; + const atlasImage = atlasImages[accessoryIndex]; + const cell = DOM.append(gallery, DOM.$('.chat-pet-all-runtime-state-cell')); + cell.style.display = 'flex'; + cell.style.justifyContent = 'space-around'; + cell.style.gap = '8px'; + cell.style.padding = '8px'; + cell.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border)'; + cell.style.borderRadius = 'var(--vscode-cornerRadius-medium)'; + cell.style.background = 'var(--vscode-editorWidget-background)'; + + for (const facing of ['right', 'left'] as const) { + const direction = DOM.append(cell, DOM.$('.chat-pet-all-runtime-direction')); + direction.style.display = 'flex'; + direction.style.flexDirection = 'column'; + direction.style.alignItems = 'center'; + direction.style.gap = '4px'; + const displayScale = Math.min(0.5, 68 / source.frameWidth); + const displayWidth = source.frameWidth * displayScale; + const displayHeight = source.frameHeight * displayScale; + const stage = DOM.append(direction, DOM.$('.chat-pet-all-runtime-stage')); + stage.style.position = 'relative'; + stage.style.width = `${displayWidth}px`; + stage.style.height = `${displayHeight}px`; + const bodyCanvas = DOM.append(stage, DOM.$('canvas')) as HTMLCanvasElement; + bodyCanvas.width = source.frameWidth; + bodyCanvas.height = source.frameHeight; + bodyCanvas.style.display = 'block'; + bodyCanvas.style.width = `${displayWidth}px`; + bodyCanvas.style.height = `${displayHeight}px`; + bodyCanvas.style.imageRendering = 'pixelated'; + bodyCanvas.style.transform = facing === 'left' && !source.fixedOrientationDecorations ? 'scaleX(-1)' : ''; + bodyCanvas.setAttribute('aria-hidden', 'true'); + const context = bodyCanvas.getContext('2d'); + if (!context) { + throw new Error('All-state fixture canvas context unavailable.'); + } + context.imageSmoothingEnabled = false; + drawChatPetComposite( + context, + bodyImage, + atlasImage, + source.frameIndex, + source.rigFrameIndex, + source.frameWidth, + source.frameHeight, + source.fixedOrientationDecorations ? facing : 'right', + state, + source.fixedOrientationDecorations, + false, + accessory.eyeAccessoryMirrorsWithFacing !== false, + accessory.coversAntennae === true, + ); + const eyeCanvas = DOM.append(stage, DOM.$('canvas')) as HTMLCanvasElement; + eyeCanvas.width = source.frameWidth; + eyeCanvas.height = source.frameHeight; + eyeCanvas.style.position = 'absolute'; + eyeCanvas.style.inset = '0'; + eyeCanvas.style.width = `${displayWidth}px`; + eyeCanvas.style.height = `${displayHeight}px`; + eyeCanvas.style.imageRendering = 'pixelated'; + const mirrorsWithFacing = accessory.eyeAccessoryMirrorsWithFacing !== false; + eyeCanvas.style.transform = facing === 'left' && mirrorsWithFacing && !source.fixedOrientationDecorations ? 'scaleX(-1)' : ''; + eyeCanvas.setAttribute('aria-hidden', 'true'); + const eyeContext = eyeCanvas.getContext('2d'); + if (!eyeContext) { + throw new Error('All-state fixture eye accessory canvas unavailable.'); + } + eyeContext.imageSmoothingEnabled = false; + const eyeFacing = source.fixedOrientationDecorations || !mirrorsWithFacing ? facing : 'right'; + drawChatPetEyeAccessory(eyeContext, atlasImage, state, source.rigFrameIndex, eyeFacing, mirrorsWithFacing); + const facingLabel = DOM.append(direction, DOM.$('span')); + facingLabel.textContent = facing === 'right' ? 'Right' : 'Left'; + facingLabel.style.fontSize = 'var(--vscode-fontSize-small)'; + facingLabel.style.color = 'var(--vscode-descriptionForeground)'; + } + } + } +} + +async function renderAllAccessoriesFacing(ctx: ComponentFixtureContext): Promise<void> { + configureChatPetFixtureFileRoot(ctx.disposableStore); + ctx.container.style.width = '900px'; + ctx.container.style.height = '1080px'; + ctx.container.style.boxSizing = 'border-box'; + ctx.container.style.padding = '24px'; + ctx.container.style.overflow = 'auto'; + ctx.container.style.background = 'var(--vscode-editor-background)'; + ctx.container.style.color = 'var(--vscode-foreground)'; + + const heading = DOM.append(ctx.container, DOM.$('h1')); + heading.textContent = 'Accessory facing'; + heading.style.margin = '0 0 8px'; + const description = DOM.append(ctx.container, DOM.$('p')); + description.textContent = 'Canonical right-facing art mirrors with the pet for left-facing poses.'; + description.style.margin = '0 0 20px'; + description.style.color = 'var(--vscode-descriptionForeground)'; + + const bodyUrl = FileAccess.asBrowserUri('vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-idle-stable-96.png').toString(true); + const bodyImage = await loadImage(bodyUrl); + if (!hasChatPetBodyImageDimensions(bodyImage, 96, 96, 1)) { + throw new Error(`Invalid fixture body dimensions: ${bodyUrl}`); + } + const atlasImages = await Promise.all(chatPetAccessories.map(async accessory => { + const source = getChatPetAccessoryImageSource(accessory); + const image = await loadImage(source.url); + if (!hasChatPetAccessoryImageDimensions(image, source)) { + throw new Error(`Invalid accessory atlas dimensions: ${source.url}`); + } + return image; + })); + + const grid = DOM.append(ctx.container, DOM.$('.chat-pet-accessory-facing-grid')); + grid.style.display = 'grid'; + grid.style.gridTemplateColumns = 'repeat(2, minmax(0, 1fr))'; + grid.style.gap = '16px'; + for (let index = 0; index < chatPetAccessories.length; index++) { + const accessory = chatPetAccessories[index]; + const atlasImage = atlasImages[index]; + const card = DOM.append(grid, DOM.$('.chat-pet-accessory-facing-card')); + card.style.padding = '12px'; + card.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border)'; + card.style.borderRadius = 'var(--vscode-cornerRadius-medium)'; + card.style.background = 'var(--vscode-editorWidget-background)'; + const label = DOM.append(card, DOM.$('h2')); + label.textContent = accessory.label; + label.style.margin = '0 0 12px'; + label.style.fontSize = 'var(--vscode-fontSize-heading3)'; + const directions = DOM.append(card, DOM.$('.chat-pet-accessory-facing-directions')); + directions.style.display = 'flex'; + directions.style.justifyContent = 'space-around'; + for (const facing of ['right', 'left'] as const) { + const preview = DOM.append(directions, DOM.$('.chat-pet-accessory-facing-preview')); + preview.style.textAlign = 'center'; + const stage = DOM.append(preview, DOM.$('.chat-pet-accessory-facing-stage')); + stage.style.position = 'relative'; + stage.style.width = '72px'; + stage.style.height = '72px'; + const bodyCanvas = DOM.append(stage, DOM.$('canvas')) as HTMLCanvasElement; + bodyCanvas.width = 96; + bodyCanvas.height = 96; + bodyCanvas.style.display = 'block'; + bodyCanvas.style.width = '72px'; + bodyCanvas.style.height = '72px'; + bodyCanvas.style.imageRendering = 'pixelated'; + bodyCanvas.style.transform = facing === 'left' ? 'scaleX(-1)' : ''; + bodyCanvas.setAttribute('aria-hidden', 'true'); + const bodyContext = bodyCanvas.getContext('2d'); + if (!bodyContext) { + throw new Error('Canvas rendering context unavailable.'); + } + bodyContext.imageSmoothingEnabled = false; + drawChatPetComposite(bodyContext, bodyImage, atlasImage, 0, 0, 96, 96, 'right', 'idle', undefined, false, true, accessory.coversAntennae === true); + const eyeCanvas = DOM.append(stage, DOM.$('canvas')) as HTMLCanvasElement; + eyeCanvas.width = 96; + eyeCanvas.height = 96; + eyeCanvas.style.position = 'absolute'; + eyeCanvas.style.inset = '0'; + eyeCanvas.style.width = '72px'; + eyeCanvas.style.height = '72px'; + eyeCanvas.style.imageRendering = 'pixelated'; + const mirrorsWithFacing = accessory.eyeAccessoryMirrorsWithFacing !== false; + eyeCanvas.style.transform = facing === 'left' && mirrorsWithFacing ? 'scaleX(-1)' : ''; + eyeCanvas.setAttribute('aria-hidden', 'true'); + const eyeContext = eyeCanvas.getContext('2d'); + if (!eyeContext) { + throw new Error('Eye accessory canvas unavailable.'); + } + eyeContext.imageSmoothingEnabled = false; + const eyeFacing = facing === 'left' && !mirrorsWithFacing ? 'left' : 'right'; + drawChatPetEyeAccessory(eyeContext, atlasImage, 'idle', 0, eyeFacing, mirrorsWithFacing); + const facingLabel = DOM.append(preview, DOM.$('span')); + facingLabel.textContent = facing === 'right' ? 'Right' : 'Left'; + } + } +} + +async function renderCoveredAntennaeComparison(ctx: ComponentFixtureContext): Promise<void> { + configureChatPetFixtureFileRoot(ctx.disposableStore); + ctx.container.style.width = '1240px'; + ctx.container.style.height = '1000px'; + ctx.container.style.boxSizing = 'border-box'; + ctx.container.style.padding = '24px'; + ctx.container.style.overflow = 'auto'; + ctx.container.style.background = 'var(--vscode-editor-background)'; + ctx.container.style.color = 'var(--vscode-foreground)'; + + const heading = DOM.append(ctx.container, DOM.$('h1')); + heading.textContent = 'Production accessory motion'; + heading.style.margin = '0 0 8px'; + const description = DOM.append(ctx.container, DOM.$('p')); + description.textContent = 'All 11 achievement rewards use body-owned attachment tracks and transparent antenna occlusion in both directions.'; + description.style.margin = '0 0 20px'; + description.style.color = 'var(--vscode-descriptionForeground)'; + + const idleBodyUrl = FileAccess.asBrowserUri('vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-idle-stable-96.png').toString(true); + const sleepBodyUrl = FileAccess.asBrowserUri('vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-sleep-stable-96.png').toString(true); + const jumpBodyUrl = FileAccess.asBrowserUri('vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-jump-stable-96.spritesheet.png').toString(true); + const fallingBodyUrl = FileAccess.asBrowserUri('vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-falling-stable-96.spritesheet.png').toString(true); + const impactBodyUrl = FileAccess.asBrowserUri('vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-wall-impact-stable-96.png').toString(true); + const [idleBodyImage, sleepBodyImage, jumpBodyImage, fallingBodyImage, impactBodyImage] = await Promise.all([ + loadImage(idleBodyUrl), + loadImage(sleepBodyUrl), + loadImage(jumpBodyUrl), + loadImage(fallingBodyUrl), + loadImage(impactBodyUrl), + ]); + if (!hasChatPetBodyImageDimensions(idleBodyImage, 96, 96, 1) + || !hasChatPetBodyImageDimensions(sleepBodyImage, 120, 96, 1) + || !hasChatPetBodyImageDimensions(jumpBodyImage, 96, 96, 6) + || !hasChatPetBodyImageDimensions(fallingBodyImage, 96, 96, 6) + || !hasChatPetBodyImageDimensions(impactBodyImage, 96, 96, 1)) { + throw new Error('Invalid covered-antennae comparison body dimensions.'); + } + + const accessoryImages = await Promise.all(productionAccessoryPreviews.map(async preview => { + const accessory = allChatPetAccessories.find(accessory => accessory.id === preview.accessoryId); + if (!accessory) { + throw new Error(`Unknown fixture accessory: ${preview.accessoryId}`); + } + const source = getChatPetAccessoryImageSource(accessory); + const image = await loadImage(source.url); + if (!hasChatPetAccessoryImageDimensions(image, source)) { + throw new Error(`Invalid production accessory dimensions: ${accessory.atlasName}`); + } + return { accessory, image }; + })); + + const grid = DOM.append(ctx.container, DOM.$('.chat-pet-covered-antennae-grid')); + grid.style.display = 'grid'; + grid.style.gridTemplateColumns = '220px repeat(10, minmax(92px, 1fr))'; + grid.style.gap = '8px'; + grid.style.alignItems = 'stretch'; + + for (const text of ['Appearance', 'Idle R', 'Idle L', 'Sleep R', 'Sleep L', 'Jump R', 'Jump L', 'Fall R', 'Fall L', 'Wall R', 'Wall L']) { + const header = DOM.append(grid, DOM.$('.chat-pet-covered-antennae-header')); + header.textContent = text; + header.style.position = 'sticky'; + header.style.top = '0'; + header.style.zIndex = '1'; + header.style.padding = '10px'; + header.style.background = 'var(--vscode-editorWidget-background)'; + header.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border)'; + header.style.fontWeight = '600'; + } + + const idleFrame: IChatPetFixtureFrame = { + state: 'idle', + frameWidth: 96, + frameHeight: 96, + bodyFrameIndex: 0, + rigFrameIndex: 0, + }; + const sleepFrame: IChatPetFixtureFrame = { + state: 'sleep', + frameWidth: 120, + frameHeight: 96, + bodyFrameIndex: 0, + rigFrameIndex: getChatPetReducedMotionRigFrame('sleep'), + }; + const jumpFrame: IChatPetFixtureFrame = { + state: 'jump', + frameWidth: 96, + frameHeight: 96, + bodyFrameIndex: 3, + rigFrameIndex: 3, + }; + const fallingFrame: IChatPetFixtureFrame = { + state: 'falling', + frameWidth: 96, + frameHeight: 96, + bodyFrameIndex: 0, + rigFrameIndex: 0, + }; + const impactFrame: IChatPetFixtureFrame = { + state: 'wallImpact', + frameWidth: 96, + frameHeight: 96, + bodyFrameIndex: 0, + rigFrameIndex: 0, + }; + + for (let index = 0; index < productionAccessoryPreviews.length; index++) { + const preview = productionAccessoryPreviews[index]; + const { accessory, image } = accessoryImages[index]; + const appearance = DOM.append(grid, DOM.$('.chat-pet-covered-antennae-label')); + appearance.style.padding = '12px'; + appearance.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border)'; + appearance.style.borderRadius = 'var(--vscode-cornerRadius-medium)'; + appearance.style.background = 'var(--vscode-editorWidget-background)'; + const label = DOM.append(appearance, DOM.$('strong')); + label.textContent = accessory.label; + label.style.display = 'block'; + const shape = DOM.append(appearance, DOM.$('span')); + shape.textContent = preview.shape; + shape.style.display = 'block'; + shape.style.marginTop = '4px'; + shape.style.color = 'var(--vscode-descriptionForeground)'; + + for (const facing of ['right', 'left'] as const) { + appendCoveredAntennaeCell(grid, idleBodyImage, image, accessory, idleFrame, facing); + } + for (const facing of ['right', 'left'] as const) { + appendCoveredAntennaeCell(grid, sleepBodyImage, image, accessory, sleepFrame, facing); + } + for (const facing of ['right', 'left'] as const) { + appendCoveredAntennaeCell(grid, jumpBodyImage, image, accessory, jumpFrame, facing); + } + for (const facing of ['right', 'left'] as const) { + appendCoveredAntennaeCell(grid, fallingBodyImage, image, accessory, fallingFrame, facing); + } + for (const facing of ['right', 'left'] as const) { + appendCoveredAntennaeCell(grid, impactBodyImage, image, accessory, impactFrame, facing, facing === 'right' ? 90 : -90); + } + } +} + +function appendCoveredAntennaeCell( + parent: HTMLElement, + bodyImage: HTMLImageElement, + accessoryImage: HTMLImageElement, + accessory: Pick<IChatPetAccessory, 'eyeAccessoryMirrorsWithFacing' | 'coversAntennae'>, + frame: IChatPetFixtureFrame, + facing: 'left' | 'right', + rotation = 0, +): void { + const cell = DOM.append(parent, DOM.$('.chat-pet-covered-antennae-cell')); + cell.style.display = 'flex'; + cell.style.alignItems = 'center'; + cell.style.justifyContent = 'center'; + cell.style.minHeight = '92px'; + cell.style.padding = '8px'; + cell.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border)'; + cell.style.borderRadius = 'var(--vscode-cornerRadius-medium)'; + cell.style.background = 'var(--vscode-editorWidget-background)'; + appendChatPetFacingPreview(cell, bodyImage, accessoryImage, accessory, frame, facing, 0.75, rotation); +} + +async function renderLiveEyeLayering(ctx: ComponentFixtureContext): Promise<void> { + configureChatPetFixtureFileRoot(ctx.disposableStore); + ctx.container.style.width = '600px'; + ctx.container.style.height = '360px'; + ctx.container.style.boxSizing = 'border-box'; + ctx.container.style.padding = '24px'; + ctx.container.style.background = 'var(--vscode-editor-background)'; + ctx.container.style.color = 'var(--vscode-foreground)'; + + const heading = DOM.append(ctx.container, DOM.$('h1')); + heading.textContent = 'Live monocle layering'; + heading.style.margin = '0 0 8px'; + const description = DOM.append(ctx.container, DOM.$('p')); + description.textContent = 'The identity-bound monocle follows the same gaze offset as the shifted DOM pupil.'; + description.style.margin = '0 0 24px'; + description.style.color = 'var(--vscode-descriptionForeground)'; + + const bodyUrl = FileAccess.asBrowserUri('vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-idle-stable-tracking-96.png').toString(true); + const bodyImage = await loadImage(bodyUrl); + const accessory = getChatPetAccessory(ChatPetAccessoryIds.TopHatMonocle); + const accessorySource = getChatPetAccessoryImageSource(accessory); + const accessoryImage = await loadImage(accessorySource.url); + if (!hasChatPetBodyImageDimensions(bodyImage, 96, 96, 1) || !hasChatPetAccessoryImageDimensions(accessoryImage, accessorySource)) { + throw new Error('Invalid live eye-layering fixture assets.'); + } + + const directions = DOM.append(ctx.container, DOM.$('.chat-pet-live-eye-directions')); + directions.style.display = 'flex'; + directions.style.justifyContent = 'space-around'; + for (const facing of ['right', 'left'] as const) { + const card = DOM.append(directions, DOM.$('.chat-pet-live-eye-card')); + card.style.width = '220px'; + card.style.padding = '16px'; + card.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border)'; + card.style.borderRadius = 'var(--vscode-cornerRadius-medium)'; + card.style.background = 'var(--vscode-editorWidget-background)'; + const stage = DOM.append(card, DOM.$('.chat-pet-live-eye-stage')); + stage.style.position = 'relative'; + stage.style.width = '96px'; + stage.style.height = '96px'; + stage.style.margin = '0 auto 12px'; + const button = DOM.append(stage, DOM.$('.chat-pet-button')); + button.dataset.state = 'idle'; + button.dataset.facing = facing; + button.style.position = 'absolute'; + button.style.right = 'auto'; + button.style.bottom = '0'; + button.style.left = '24px'; + button.style.transform = 'scale(2)'; + button.style.transformOrigin = 'bottom left'; + const visual = DOM.append(button, DOM.$('.chat-pet-visual')); + const sprite = DOM.append(visual, DOM.$('.chat-pet-sprite')); + const bodyCanvas = DOM.append(sprite, DOM.$('canvas.chat-pet-canvas')) as HTMLCanvasElement; + bodyCanvas.width = 96; + bodyCanvas.height = 96; + const bodyContext = bodyCanvas.getContext('2d'); + if (!bodyContext) { + throw new Error('Body canvas unavailable.'); + } + bodyContext.imageSmoothingEnabled = false; + drawChatPetComposite(bodyContext, bodyImage, accessoryImage, 0, 0, 96, 96, 'right', 'idle', undefined, false, false, accessory.coversAntennae === true); + + const eyes = DOM.append(visual, DOM.$('.chat-pet-eyes.tracking')); + for (const side of ['left', 'right']) { + const eye = DOM.append(eyes, DOM.$(`.chat-pet-eye.${side}`)); + const pupil = DOM.append(eye, DOM.$('.chat-pet-pupil')); + if (side === 'right') { + pupil.style.transform = 'translateX(2px)'; + } + } + const eyeAccessory = DOM.append(visual, DOM.$('.chat-pet-eye-accessory.fixed-orientation')); + const eyeCanvas = DOM.append(eyeAccessory, DOM.$('canvas.chat-pet-eye-accessory-canvas')) as HTMLCanvasElement; + eyeCanvas.width = 96; + eyeCanvas.height = 96; + const eyeContext = eyeCanvas.getContext('2d'); + if (!eyeContext) { + throw new Error('Eye accessory canvas unavailable.'); + } + eyeContext.imageSmoothingEnabled = false; + drawChatPetEyeAccessory(eyeContext, accessoryImage, 'idle', 0, facing, false, [4, 0]); + const label = DOM.append(card, DOM.$('div')); + label.textContent = facing === 'right' ? 'Right' : 'Left'; + label.style.textAlign = 'center'; + } +} + +async function renderAchievementUnlockStar(ctx: ComponentFixtureContext): Promise<void> { + configureChatPetFixtureFileRoot(ctx.disposableStore); + ctx.container.style.width = '420px'; + ctx.container.style.height = '220px'; + ctx.container.style.boxSizing = 'border-box'; + ctx.container.style.padding = '24px'; + ctx.container.style.background = 'var(--vscode-editor-background)'; + ctx.container.style.color = 'var(--vscode-foreground)'; + + const heading = DOM.append(ctx.container, DOM.$('h1')); + heading.textContent = 'Achievement unlock star'; + heading.style.margin = '0 0 20px'; + const row = DOM.append(ctx.container, DOM.$('.chat-pet-achievement-star-row')); + row.style.display = 'flex'; + row.style.gap = '24px'; + for (const variant of ['stable', 'insiders'] as const) { + const image = await loadImage(FileAccess.asBrowserUri(`vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-speech-${variant}-96.png`).toString(true)); + const card = DOM.append(row, DOM.$('.chat-pet-achievement-star-card')); + const canvas = DOM.append(card, DOM.$('canvas')) as HTMLCanvasElement; + canvas.width = 96; + canvas.height = 96; + canvas.style.display = 'block'; + canvas.style.width = '144px'; + canvas.style.height = '144px'; + canvas.style.imageRendering = 'pixelated'; + canvas.setAttribute('aria-hidden', 'true'); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Achievement star canvas unavailable.'); + } + context.imageSmoothingEnabled = false; + context.drawImage(image, 0, 0); + drawChatPetAchievementStar(context, variant); + } +} + +interface IAllRuntimeStateBodySource { + readonly url: string; + readonly frameWidth: number; + readonly frameHeight: number; + readonly frameCount: number; + readonly frameIndex: number; + readonly rigFrameIndex: number; + readonly fixedOrientationDecorations?: typeof CHAT_PET_SING_FIXED_ORIENTATION_DECORATIONS; +} + +function getAllRuntimeStateBodySource(state: ChatPetState): IAllRuntimeStateBodySource { + const frameDurations = getChatPetFrameDurations(state); + const frameCount = Math.max(1, frameDurations.length); + const frameWidth = state === 'sleep' || state === 'waking' + ? 120 + : state === 'typing' + ? 168 + : state === 'buttonPress' + ? 160 + : state === 'sing' + ? 164 + : 96; + const frameHeight = state === 'dizzy' ? 128 : state === 'sing' ? 124 : 96; + const suffix = doesChatPetStateTrackCursor(state) ? '-tracking-96' : `-${frameHeight}`; + const name = getChatPetSpriteName(state, 'stable'); + const root = 'vs/workbench/contrib/chat/browser/widget/media/chatPet'; + const frameIndex = Math.min(getChatPetReducedMotionRigFrame(state), frameCount - 1); + return { + url: FileAccess.asBrowserUri(`${root}/${name}${suffix}${frameCount > 1 ? '.spritesheet' : ''}.png`).toString(true), + frameWidth, + frameHeight, + frameCount, + frameIndex, + rigFrameIndex: frameIndex, + fixedOrientationDecorations: state === 'sing' ? CHAT_PET_SING_FIXED_ORIENTATION_DECORATIONS : undefined, + }; +} + +function getChatPetStateLabel(state: ChatPetState): string { + return state.replace(/[A-Z]/g, character => ` ${character.toLowerCase()}`).replace(/^./, character => character.toUpperCase()); +} + +function appendChatPetFacingPreview( + parent: HTMLElement, + bodyImage: HTMLImageElement, + accessoryImage: HTMLImageElement, + accessory: Pick<IChatPetAccessory, 'eyeAccessoryMirrorsWithFacing' | 'coversAntennae'>, + frame: IChatPetFixtureFrame, + facing: 'left' | 'right', + displayScale: number, + rotation: number, +): void { + const stage = DOM.append(parent, DOM.$('.chat-pet-covered-antennae-stage')); + stage.style.position = 'relative'; + stage.style.width = `${frame.frameWidth * displayScale}px`; + stage.style.height = `${frame.frameHeight * displayScale}px`; + stage.style.transform = rotation === 0 ? '' : `rotate(${rotation}deg)`; + + const bodyCanvas = DOM.append(stage, DOM.$('canvas')) as HTMLCanvasElement; + bodyCanvas.width = frame.frameWidth; + bodyCanvas.height = frame.frameHeight; + bodyCanvas.style.display = 'block'; + bodyCanvas.style.width = `${frame.frameWidth * displayScale}px`; + bodyCanvas.style.height = `${frame.frameHeight * displayScale}px`; + bodyCanvas.style.imageRendering = 'pixelated'; + bodyCanvas.style.transform = facing === 'left' ? 'scaleX(-1)' : ''; + bodyCanvas.setAttribute('aria-hidden', 'true'); + const bodyContext = bodyCanvas.getContext('2d'); + if (!bodyContext) { + throw new Error('Covered-antennae fixture body canvas unavailable.'); + } + bodyContext.imageSmoothingEnabled = false; + drawChatPetComposite( + bodyContext, + bodyImage, + accessoryImage, + frame.bodyFrameIndex, + frame.rigFrameIndex, + frame.frameWidth, + frame.frameHeight, + 'right', + frame.state, + undefined, + false, + accessory.eyeAccessoryMirrorsWithFacing !== false, + accessory.coversAntennae === true, + ); + + const eyeCanvas = DOM.append(stage, DOM.$('canvas')) as HTMLCanvasElement; + eyeCanvas.width = frame.frameWidth; + eyeCanvas.height = frame.frameHeight; + eyeCanvas.style.position = 'absolute'; + eyeCanvas.style.inset = '0'; + eyeCanvas.style.width = `${frame.frameWidth * displayScale}px`; + eyeCanvas.style.height = `${frame.frameHeight * displayScale}px`; + eyeCanvas.style.imageRendering = 'pixelated'; + eyeCanvas.setAttribute('aria-hidden', 'true'); + const eyeContext = eyeCanvas.getContext('2d'); + if (!eyeContext) { + throw new Error('Covered-antennae fixture eye accessory canvas unavailable.'); + } + eyeContext.imageSmoothingEnabled = false; + const mirrorsWithFacing = accessory.eyeAccessoryMirrorsWithFacing !== false; + eyeCanvas.style.transform = facing === 'left' && mirrorsWithFacing ? 'scaleX(-1)' : ''; + drawChatPetEyeAccessory( + eyeContext, + accessoryImage, + frame.state, + frame.rigFrameIndex, + facing === 'left' && !mirrorsWithFacing ? 'left' : 'right', + mirrorsWithFacing, + ); +} + +function loadImage(url: string): Promise<HTMLImageElement> { + return new Promise((resolve, reject) => { + const image = mainWindow.document.createElement('img'); + image.addEventListener('load', () => resolve(image), { once: true }); + image.addEventListener('error', () => reject(new Error(`Failed to load fixture image: ${url}`)), { once: true }); + image.src = url; + }); +} diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts new file mode 100644 index 00000000000000..02a2ce34bebc91 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Dimension } from '../../../../../base/browser/dom.js'; +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 { 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 { 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; +} + +function createMockEditorGroup(): IEditorGroup { + return new class extends mock<IEditorGroup>() { + override windowId = mainWindow.vscodeWindowId; + }(); +} + +async function renderAchievementsEditor(context: ComponentFixtureContext, options: IAchievementsEditorFixtureOptions): Promise<void> { + const width = options.width ?? 900; + const height = options.height ?? 600; + context.container.style.width = `${width}px`; + context.container.style.height = `${height}px`; + configureChatPetFixtureFileRoot(context.disposableStore); + + const chatPetService = context.disposableStore.add(new FixtureChatPetService(options)); + const instantiationService = createEditorServices(context.disposableStore, { + colorTheme: context.theme, + additionalServices: registry => { + registerWorkbenchServices(registry); + registry.defineInstance(IChatPetService, chatPetService); + }, + }); + 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); +} + +export default defineThemedFixtureGroup({ path: 'chat/petAchievements/standaloneModal/' }, { + AllLocked: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderAchievementsEditor(context, { enabled: true }), + }), + MixedNoHat: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderAchievementsEditor(context, { + enabled: true, + unlockedAchievements: [ChatPetAchievementIds.RequestRevision, ChatPetAchievementIds.FirstChatMessage], + unseenAchievements: [ChatPetAchievementIds.FirstChatMessage], + }), + }), + MixedSelected: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: context => renderAchievementsEditor(context, { + enabled: true, + unlockedAchievements: [ChatPetAchievementIds.RequestRevision, ChatPetAchievementIds.FirstChatMessage], + unseenAchievements: [ChatPetAchievementIds.FirstChatMessage], + selectedAccessory: ChatPetAccessoryIds.TopHatMonocle, + }), + }), + MediumMixed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderAchievementsEditor(context, { + enabled: true, + unlockedAchievements: [ChatPetAchievementIds.RequestRevision, ChatPetAchievementIds.FirstChatMessage], + unseenAchievements: [ChatPetAchievementIds.FirstChatMessage], + selectedAccessory: ChatPetAccessoryIds.CowboyHat, + width: 700, + height: 500, + }), + }), + AllUnlocked: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderAchievementsEditor(context, { + enabled: true, + unlockedAchievements: chatPetAchievements.map(achievement => achievement.id), + selectedAccessory: ChatPetAccessoryIds.Crown, + variant: 'insiders', + }), + }), + NarrowMixed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderAchievementsEditor(context, { + enabled: true, + unlockedAchievements: [ChatPetAchievementIds.IntegratedBrowserShared], + width: 550, + height: 500, + }), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts new file mode 100644 index 00000000000000..9b83035c12f6ed --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../../base/browser/window.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +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'; + +export interface IChatPetFixtureOptions { + readonly enabled: boolean; + readonly unlockedAchievements?: readonly ChatPetAchievementId[]; + readonly unseenAchievements?: readonly ChatPetAchievementId[]; + readonly selectedAccessory?: ChatPetAccessoryId; + readonly variant?: ChatPetVariant; +} + +export class FixtureChatPetService extends Disposable implements IChatPetService { + + declare readonly _serviceBrand: undefined; + + private readonly enabledValue: ISettableObservable<boolean>; + readonly enabled: IObservable<boolean>; + private readonly variantValue: ISettableObservable<ChatPetVariant>; + readonly variant: IObservable<ChatPetVariant>; + private readonly onTheRunValue = observableValue(this, false); + readonly onTheRun: IObservable<boolean> = this.onTheRunValue; + private readonly scaleValue = observableValue(this, 1); + readonly scale: IObservable<number> = this.scaleValue; + private readonly horizontalPositionValue = observableValue<number | undefined>(this, undefined); + readonly horizontalPosition: IObservable<number | undefined> = this.horizontalPositionValue; + private readonly unlockedAchievementsValue: ISettableObservable<readonly ChatPetAchievementId[]>; + readonly unlockedAchievements: IObservable<readonly ChatPetAchievementId[]>; + private readonly unseenAchievementsValue: ISettableObservable<readonly ChatPetAchievementId[]>; + readonly unseenAchievements: IObservable<readonly ChatPetAchievementId[]>; + private readonly selectedAccessoryValue: ISettableObservable<ChatPetAccessoryId | undefined>; + readonly selectedAccessory: IObservable<ChatPetAccessoryId | undefined>; + readonly onDidUnlockAchievement = Event.None; + + constructor(options: IChatPetFixtureOptions) { + super(); + this.enabledValue = observableValue(this, options.enabled); + this.enabled = this.enabledValue; + this.variantValue = observableValue(this, options.variant ?? 'stable'); + this.variant = this.variantValue; + this.unlockedAchievementsValue = observableValue<readonly ChatPetAchievementId[]>(this, options.unlockedAchievements ?? []); + this.unlockedAchievements = this.unlockedAchievementsValue; + this.unseenAchievementsValue = observableValue<readonly ChatPetAchievementId[]>(this, options.unseenAchievements ?? []); + this.unseenAchievements = this.unseenAchievementsValue; + this.selectedAccessoryValue = observableValue<ChatPetAccessoryId | undefined>(this, options.selectedAccessory); + this.selectedAccessory = this.selectedAccessoryValue; + } + + toggle(): boolean { + const enabled = !this.enabledValue.get(); + this.enabledValue.set(enabled, undefined); + return enabled; + } + + setVariant(variant: ChatPetVariant): void { + this.variantValue.set(variant, undefined); + } + + setOnTheRun(onTheRun: boolean): void { + this.onTheRunValue.set(onTheRun, undefined); + } + + setScale(scale: number): void { + this.scaleValue.set(scale, undefined); + } + + setHorizontalPosition(position: number): void { + this.horizontalPositionValue.set(position, undefined); + } + + unlockAchievement(id: ChatPetAchievementId): boolean { + if (!this.enabledValue.get() || this.unlockedAchievementsValue.get().includes(id)) { + return false; + } + this.unlockedAchievementsValue.set([...this.unlockedAchievementsValue.get(), id], undefined); + this.unseenAchievementsValue.set([...this.unseenAchievementsValue.get(), id], undefined); + return true; + } + + markAchievementSeen(id: ChatPetAchievementId): boolean { + if (!this.unseenAchievementsValue.get().includes(id)) { + return false; + } + this.unseenAchievementsValue.set(this.unseenAchievementsValue.get().filter(candidate => candidate !== id), undefined); + return true; + } + + setAccessory(id: ChatPetAccessoryId | undefined): void { + this.selectedAccessoryValue.set(id, undefined); + } + + resetAchievements(): void { + this.unlockedAchievementsValue.set([], undefined); + this.unseenAchievementsValue.set([], undefined); + this.selectedAccessoryValue.set(undefined, undefined); + } +} + +export function configureChatPetFixtureFileRoot(disposableStore: DisposableStore): void { + const previousFileRoot = globalThis._VSCODE_FILE_ROOT; + globalThis._VSCODE_FILE_ROOT = `${mainWindow.location.origin}/src/`; + disposableStore.add(toDisposable(() => globalThis._VSCODE_FILE_ROOT = previousFileRoot)); +} diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/chatPetAchievementBadges.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/chatPetAchievementBadges.fixture.ts new file mode 100644 index 00000000000000..963e78597e0f08 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/chatPetAchievementBadges.fixture.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { chatPetAchievements, ChatPetAchievementIds } from '../../../../contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../contrib/chat/browser/chatPetService.js'; +// eslint-disable-next-line local/code-import-patterns +import { SessionsChatPetAchievementBadges } from '../../../../../sessions/contrib/accountMenu/browser/chatPetAchievementBadges.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { configureChatPetFixtureFileRoot, FixtureChatPetService, IChatPetFixtureOptions } from '../chat/chatPetFixtureUtils.js'; + +function renderAchievementBadges(context: ComponentFixtureContext, options: IChatPetFixtureOptions): void { + context.container.classList.add('agent-sessions-workbench'); + context.container.style.width = '400px'; + configureChatPetFixtureFileRoot(context.disposableStore); + + const chatPetService = context.disposableStore.add(new FixtureChatPetService(options)); + const instantiationService = createEditorServices(context.disposableStore, { + colorTheme: context.theme, + additionalServices: registry => { + registry.defineInstance(IChatPetService, chatPetService); + }, + }); + const panel = DOM.append(context.container, DOM.$('.sessions-account-titlebar-panel')); + panel.style.width = '400px'; + const widget = context.disposableStore.add(instantiationService.createInstance(SessionsChatPetAchievementBadges, panel, () => { })); + if (widget.element.classList.contains('hidden') !== !options.enabled) { + throw new Error('Pet achievement badges fixture visibility did not match pet enablement.'); + } +} + +export default defineThemedFixtureGroup({ path: 'sessions/accountMenu/petAchievementBadges/' }, { + NoBadges: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderAchievementBadges(context, { enabled: true }), + }), + Partial: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderAchievementBadges(context, { + enabled: true, + unlockedAchievements: [ + ChatPetAchievementIds.RequestRevision, + ChatPetAchievementIds.IntegratedBrowserShared, + ChatPetAchievementIds.McpServerPresent, + ], + }), + }), + AllBadges: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: context => renderAchievementBadges(context, { + enabled: true, + unlockedAchievements: chatPetAchievements.map(achievement => achievement.id), + variant: 'insiders', + }), + }), +}); diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index c1ad1d7ae4d9e1..aa9b771283070c 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -12,6 +12,42 @@ #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/b2c09ec89048a30ced6b193bea98d2e88455c46c89383853405936abbafe8ee0) +#### chat/chatPetAccessoryRig/chatPetAccessoryRig/AllAccessoriesFacing/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5b70ae9273fabf3a5302943c59cecd674f1f7fe7b7b08168af3e684e8c4ab6d2) + +#### chat/chatPetAccessoryRig/chatPetAccessoryRig/AllAccessoriesFacing/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8ace11f4873c8750a65891b7b4cd90bdba5967fcd8e2bdc05c211e90747b5246) + +#### chat/chatPetAccessoryRig/chatPetAccessoryRig/AllRuntimeStates/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/45f421e7d9c4e3d90a0e7c24111f5398a763047690752234c38f749d81feda54) + +#### chat/chatPetAccessoryRig/chatPetAccessoryRig/AllRuntimeStates/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f2c790c0a0217d9183a3301442f9e9cbc4cf67434ec1a8c9ca47241ccfe99e5b) + +#### chat/chatPetAccessoryRig/chatPetAccessoryRig/CoveredAntennaeComparison/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5da807336d09dc733ea7ba4b64a45df31d18b2ce5807f1452ffaef059023e406) + +#### chat/chatPetAccessoryRig/chatPetAccessoryRig/CoveredAntennaeComparison/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/2ed413d9ae6bc99d5899d17fda368d98133be656a064f91823234eac09feeff6) + +#### chat/chatPetAccessoryRig/chatPetAccessoryRig/CriticalPoses/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/3c3b795d69792d9511ccd7b155b8bc34f3fce90784c4133b5ea69d2dc2771edf) + +#### chat/chatPetAccessoryRig/chatPetAccessoryRig/CriticalPoses/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7d5e729d3d22043a73536614d9ee2b0f152f79482c96eed0300825022bdd6143) + +#### chat/chatPetAccessoryRig/chatPetAccessoryRig/LiveEyeLayering/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/bbb2bc08101056301767c63bab777ff343651540cd4aad860fd20a17c0442991) + +#### chat/chatPetAccessoryRig/chatPetAccessoryRig/LiveEyeLayering/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/df1f42cc6f6a3eb52f36effd880cfc010b8107fccb88dcbeffbf57ae145ca2e4) + +#### chat/petAchievements/standaloneModal/chatPetAchievementsEditor/MixedSelected/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5a1609dbdbd0452d5e037bc334c8f52b119a8e142331604bc6ad3c4778b437b9) + +#### chat/petAchievements/standaloneModal/chatPetAchievementsEditor/MixedSelected/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/2ed2c21fadb55a5799671ae54206454af12cf6b7967ca280662bfb58cef58cba) + #### editor/codeEditor/CodeEditor/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/09075b2f4715fa8a8ad426165bb85ba96a15b7174259c7da7ef0c2d5e74f7f79) @@ -29,3 +65,9 @@ #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a29cfc0bf4510b57c82d9eae0d974babe7035042456326be861308cae609a1b5) + +#### sessions/accountMenu/petAchievementBadges/chatPetAchievementBadges/AllBadges/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ae6b8d79a5e88a93388fe24ca96cc5524145815a1628d844d3ff8357d40141f6) + +#### sessions/accountMenu/petAchievementBadges/chatPetAchievementBadges/AllBadges/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5cf9c737fbdbf76a5f8cbf0c40d87b0877e8633fcf432c1ac08533c89876f754) From dc7e3557b2bbf6c88d279c6140befb917507bf84 Mon Sep 17 00:00:00 2001 From: Bhavya U <bhavyau@microsoft.com> Date: Thu, 20 Aug 2026 19:33:09 -0700 Subject: [PATCH 11/15] Add semantic search to Copilot agent host sessions (#331836) * agentHost: add semantic search tool * Refactor agent host tool enablement logic and enhance semantic search documentation * agentHost: reserve semantic search tool names * agentHost: remove unused semantic search tool ID and update related logic * agentHost: fix semantic search tool hygiene * Refactor semantic search tool instructions for clarity and consolidation * agentHost: fix semantic search tool identity * agentHost: publish tools before starting turns * agentHost: trim semantic search scope * agentHost: fix semantic search execution * copilot: remove semantic search mode experiment --- extensions/copilot/package.json | 20 -- extensions/copilot/package.nls.json | 4 - .../prompts/node/agent/agentPrompt.tsx | 6 - .../node/agent/semanticSearchInstructions.tsx | 39 ---- .../node/agent/test/agentPrompt.spec.tsx | 32 --- .../tools/vscode-node/toolsService.ts | 8 - .../common/configurationService.ts | 2 - .../common/semanticSearchConstants.ts | 16 ++ .../node/copilot/copilotAgentSession.ts | 21 +- .../node/copilot/copilotSessionLauncher.ts | 8 +- .../node/copilot/toolSearchDeferral.ts | 2 + .../agentHost/test/node/copilotAgent.test.ts | 3 +- .../test/node/copilotAgentSession.test.ts | 70 ++++++ .../test/node/copilotSessionLauncher.test.ts | 27 ++- .../agentHost/agentHostActiveClientService.ts | 32 ++- .../agentHost/agentHostSessionHandler.ts | 23 +- .../agentHostToolSetEnablementService.ts | 8 + .../chat/browser/chat.shared.contribution.ts | 7 + .../agentHostClientTools.test.ts | 213 +++++++++++++++++- .../agentHostToolSetEnablementService.test.ts | 14 +- 20 files changed, 417 insertions(+), 138 deletions(-) delete mode 100644 extensions/copilot/src/extension/prompts/node/agent/semanticSearchInstructions.tsx create mode 100644 src/vs/platform/agentHost/common/semanticSearchConstants.ts diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 576eb3fcab9828..c49b5352fd8c70 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -161,7 +161,6 @@ "icon": "$(folder)", "userDescription": "%copilot.codebase.tool.description%", "modelDescription": "Run a natural language search for relevant code or documentation comments from the user's current workspace. Returns relevant code snippets from the user's current workspace if it is large, or the full contents of the workspace if it is small.", - "when": "config.github.copilot.chat.semanticSearchTool.mode != disabled", "tags": [ "codesearch", "vscode_codesearch" @@ -4127,25 +4126,6 @@ "onExp" ] }, - "github.copilot.chat.semanticSearchTool.mode": { - "type": "string", - "default": "enabled", - "enum": [ - "enabled", - "disabled", - "preferred" - ], - "markdownEnumDescriptions": [ - "%github.copilot.config.semanticSearchTool.mode.enabled%", - "%github.copilot.config.semanticSearchTool.mode.disabled%", - "%github.copilot.config.semanticSearchTool.mode.preferred%" - ], - "markdownDescription": "%github.copilot.config.semanticSearchTool.mode%", - "tags": [ - "experimental", - "onExp" - ] - }, "github.copilot.chat.anthropic.tools.websearch.enabled": { "type": "boolean", "default": false, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 3bba9870f511eb..62ec03304482c8 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -360,10 +360,6 @@ "github.copilot.config.gemini3GetChangedFilesTool.enabled": "Enables the Get Changed Files tool for gemini-3 models.", "github.copilot.config.gemini3LowReasoningEffort.enabled": "Sets the reasoning effort to low for gemini-3 models.", "github.copilot.config.gpt55ReadFileTool.enabled": "Enables the Read File tool for gpt-5.5 models.", - "github.copilot.config.semanticSearchTool.mode": "Controls how semantic search is offered to the agent in chat. Used to experiment with the impact of semantic search on agent behavior and token usage.", - "github.copilot.config.semanticSearchTool.mode.enabled": "The `semantic_search` tool and the `#codebase` chat variable are available.", - "github.copilot.config.semanticSearchTool.mode.disabled": "Removes the `semantic_search` tool from the agent and hides the `#codebase` chat variable.", - "github.copilot.config.semanticSearchTool.mode.preferred": "Same as `enabled`, plus instructions telling the agent to prefer `semantic_search` over exploratory file reads and text searches.", "github.copilot.config.anthropic.tools.websearch.enabled": "Enable Anthropic's native web search tool for BYOK Claude models. When enabled, allows Claude to search the web for current information. \n\n**Note**: This is an experimental feature only available for BYOK Anthropic Claude models.", "github.copilot.config.anthropic.tools.websearch.maxUses": "Maximum number of web searches allowed per request. Valid range is 1 to 20. Prevents excessive API calls within a single interaction. If Claude exceeds this limit, the response returns an error.", "github.copilot.config.anthropic.tools.websearch.allowedDomains": "List of domains to restrict web search results to (e.g., `[\"example.com\", \"docs.example.com\"]`). Domains should not include the HTTP/HTTPS scheme. Subdomains are automatically included. Cannot be used together with `#github.copilot.chat.anthropic.tools.websearch.blockedDomains#`; configuring both will cause web search requests to fail.", diff --git a/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx index 06e5e9e3ec0955..ef7f92fedb3844 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx @@ -48,7 +48,6 @@ import { AgentConversationHistory, AgentUserMessageInHistory } from './agentConv import './allAgentPrompts'; import { AlternateGPTPrompt, DefaultReminderInstructions, DefaultToolReferencesHint, ReminderInstructionsProps, ToolReferencesHintProps } from './defaultAgentInstructions'; import { AgentPromptCustomizations, ReminderInstructionsConstructor, ToolReferencesHintConstructor } from './promptRegistry'; -import { PreferSemanticSearchInstructions } from './semanticSearchInstructions'; import { SummarizedConversationHistory } from './summarizedConversationHistory'; import { DeferredToolListReminder } from './toolSearchInstructions'; @@ -117,8 +116,6 @@ export class AgentPrompt extends PromptElement<AgentPromptProps> { const omitBaseAgentInstructions = this.configurationService.getConfig(ConfigKey.Advanced.OmitBaseAgentInstructions); const hasMemoryTool = !!this.props.promptContext.tools?.availableTools?.find(tool => tool.name === ToolName.Memory); - const hasSemanticSearchTool = !!this.props.promptContext.tools?.availableTools?.find(tool => tool.name === ToolName.Codebase); - const preferSemanticSearch = hasSemanticSearchTool && this.configurationService.getExperimentBasedConfig(ConfigKey.SemanticSearchToolMode, this.experimentationService) === 'preferred'; const baseAgentInstructions = <> <SystemMessage> You are an expert AI programming assistant, working with a user in the VS Code editor.<br /> @@ -126,9 +123,6 @@ export class AgentPrompt extends PromptElement<AgentPromptProps> { <SafetyRules /> </SystemMessage> {instructions} - {preferSemanticSearch && <SystemMessage> - <PreferSemanticSearchInstructions availableTools={this.props.promptContext.tools?.availableTools} /> - </SystemMessage>} {hasMemoryTool && <SystemMessage> <MemoryInstructionsPrompt /> </SystemMessage>} diff --git a/extensions/copilot/src/extension/prompts/node/agent/semanticSearchInstructions.tsx b/extensions/copilot/src/extension/prompts/node/agent/semanticSearchInstructions.tsx deleted file mode 100644 index 8e3e209762dc1f..00000000000000 --- a/extensions/copilot/src/extension/prompts/node/agent/semanticSearchInstructions.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { BasePromptElementProps, PromptElement } from '@vscode/prompt-tsx'; -import type { LanguageModelToolInformation } from 'vscode'; -import { ToolName } from '../../../tools/common/toolNames'; -import { Tag } from '../base/tag'; -import { detectToolCapabilities } from './defaultAgentInstructions'; - -export interface PreferSemanticSearchInstructionsProps extends BasePromptElementProps { - readonly availableTools: readonly LanguageModelToolInformation[] | undefined; -} - -/** - * Instructions that push the agent to reach for {@link ToolName.Codebase} before spending context on - * exploratory reads and text searches. Rendered by `AgentPrompt` when the codebase tool is available - * and {@link ConfigKey.SemanticSearchToolMode} is `preferred`. - */ -export class PreferSemanticSearchInstructions extends PromptElement<PreferSemanticSearchInstructionsProps> { - render() { - const tools = detectToolCapabilities(this.props.availableTools); - const subagentTools = [ToolName.SearchSubagent, ToolName.ExploreSubagent, ToolName.CoreRunSubagent].filter(name => tools[name]); - const subagentToolList = subagentTools.map(name => `\`${name}\``).join(' or '); - - return <Tag name='semantic_search_requirements'> - `{ToolName.Codebase}` locates code by meaning rather than by exact text. Use it when you need to find relevant code but do not know which files contain it or which exact terms the repository uses. This is more efficient than speculative file reads or a trail of guessed keyword searches.<br /> - <br /> - Rules:<br /> - - For unknown-location exploration that you perform yourself, use `{ToolName.Codebase}` before guessing file paths or keywords.<br /> - {tools[ToolName.ReadFile] && <>- When a file path is already known, read that file directly with `{ToolName.ReadFile}`.<br /></>} - {tools[ToolName.FindTextInFiles] && <>- When you know the exact text to find, search for it directly with `{ToolName.FindTextInFiles}`. Do not use a chain of guessed keyword searches as a substitute for one semantic search.<br /></>} - {tools[ToolName.ReadFile] && <>- After semantic search identifies relevant code, read only the files and regions needed for the task.<br /></>} - {subagentTools.length > 0 && <>- If other instructions tell you to delegate codebase exploration to {subagentToolList}, follow those instructions instead of calling `{ToolName.Codebase}` yourself.<br /></>} - - Keep each query to a single concept and phrase it with enough context to convey intent, for example "how websocket connections are authenticated" rather than "websocket". Split a multi-concept question into separate focused queries.<br /> - </Tag>; - } -} diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx b/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx index 9714c2ebb8d654..4256aa6b70e2e3 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx @@ -243,38 +243,6 @@ testFamilies.forEach(family => { expect(rendered).not.toContain('repoMemory'); }); - test('semantic search preference instructions render only in preferred mode', async () => { - const toolsService = accessor.get(IToolsService); - const configurationService = accessor.get(IConfigurationService); - const promptContext = { - chatVariables: new ChatVariablesCollection(), - history: [], - query: 'hello', - tools: { - availableTools: toolsService.tools, - toolInvocationToken: null as never, - toolReferences: [], - } - }; - const withoutSemanticSearch = { - ...promptContext, - tools: { ...promptContext.tools, availableTools: toolsService.tools.filter(t => t.name !== ToolName.Codebase) } - }; - const rendersBlock = async (context: IBuildPromptContext) => (await agentPromptToString(accessor, context, undefined)).includes('semantic_search_requirements'); - - try { - const defaultMode = await rendersBlock(promptContext); - await configurationService.setConfig(ConfigKey.SemanticSearchToolMode, 'preferred'); - expect({ - defaultMode, - preferredMode: await rendersBlock(promptContext), - preferredModeWithoutTool: await rendersBlock(withoutSemanticSearch), - }).toEqual({ defaultMode: false, preferredMode: true, preferredModeWithoutTool: false }); - } finally { - await configurationService.setConfig(ConfigKey.SemanticSearchToolMode, 'enabled'); - } - }); - test('one attachment', async () => { await expect(await agentPromptToString(accessor, { chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]), diff --git a/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts b/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts index 3dd49ff71cd21d..0a9b5f21c31e59 100644 --- a/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts +++ b/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts @@ -326,14 +326,6 @@ export class ToolsService extends BaseToolsService { return false; } - // For semantic_search (codebase) tool, allow experimentally disabling it entirely. - if ( - tool.name === ToolName.Codebase - && this._configurationService.getExperimentBasedConfig(ConfigKey.SemanticSearchToolMode, this._experimentationService) === 'disabled' - ) { - return false; - } - // 0. Check if the tool was disabled via the tool picker. If so, it must be disabled here const toolPickerSelection = requestToolsByName.get(getContributedToolName(tool.name)); if (toolPickerSelection === false) { diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 07fa40f65641c5..4f384257ec527c 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -1007,8 +1007,6 @@ export namespace ConfigKey { export const EnableGemini3LowReasoningEffort = defineSetting<boolean>('chat.gemini3LowReasoningEffort.enabled', ConfigType.ExperimentBased, false); /** Enable read_file tool for GPT-5.5 models */ export const EnableGpt55ReadFileTool = defineSetting<boolean>('chat.gpt55ReadFileTool.enabled', ConfigType.ExperimentBased, true); - /** How the semantic_search (codebase) tool is offered to the agent: available, removed entirely, or available with instructions telling the agent to prefer it over exploratory reads and text searches. */ - export const SemanticSearchToolMode = defineSetting<'enabled' | 'disabled' | 'preferred'>('chat.semanticSearchTool.mode', ConfigType.ExperimentBased, 'enabled'); export const EnableChatImageUpload = defineSetting<boolean>('chat.imageUpload.enabled', ConfigType.Simple, true); /** Enable Anthropic web search tool for BYOK Claude models */ export const AnthropicWebSearchToolEnabled = defineSetting<boolean>('chat.anthropic.tools.websearch.enabled', ConfigType.ExperimentBased, false); diff --git a/src/vs/platform/agentHost/common/semanticSearchConstants.ts b/src/vs/platform/agentHost/common/semanticSearchConstants.ts new file mode 100644 index 00000000000000..fb4ea26f4c7f25 --- /dev/null +++ b/src/vs/platform/agentHost/common/semanticSearchConstants.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** VS Code setting that exposes the workbench semantic search to Copilot agent sessions. */ +export const CopilotSemanticSearchEnabledSettingId = 'chat.copilot.semanticSearch.enabled'; + +/** Stable contribution id of the Copilot extension's workbench semantic-search tool. */ +export const CLIENT_SEMANTIC_SEARCH_TOOL_ID = 'copilot_searchCodebase'; + +/** Runtime/model-facing name; overrides the Copilot SDK's built-in tool of the same name. */ +export const SEMANTIC_SEARCH_TOOL_NAME = 'semantic_search'; + +/** Client/workbench-facing tool reference name (`#codebase`). */ +export const CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME = 'codebase'; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 5c8886f04155a0..fef93853ca12e3 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -41,6 +41,7 @@ import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta, type IToolSearchCandidate } from '../../common/meta/agentToolCallMeta.js'; import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import { resolveCopilotConfigSlashCommandOnSend } from '../../common/copilotConfigSlashCommands.js'; import { STREAMING_TOOL_DISPLAY_INTERVAL_MS, streamingToolDisplayText } from '../../common/streamingToolCallDisplay.js'; import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAttachment } from '../../common/meta/agentFeedbackAttachments.js'; @@ -90,6 +91,21 @@ type GitHubCredentialsUpdateResult = Awaited<ReturnType<CopilotSession['rpc']['g type McpAuthHandler = NonNullable<SessionConfig['onMcpAuthRequest']>; type McpAuthRequest = Parameters<McpAuthHandler>[0]; type McpAuthResult = Awaited<ReturnType<McpAuthHandler>>; + +interface IClientToolSdkPolicy { + readonly overridesBuiltInTool?: true; + readonly skipPermission?: true; +} + +const DEFAULT_CLIENT_TOOL_SDK_POLICY: IClientToolSdkPolicy = {}; +const CLIENT_TOOL_SDK_POLICIES: ReadonlyMap<string, IClientToolSdkPolicy> = new Map([ + [SEMANTIC_SEARCH_TOOL_NAME, { overridesBuiltInTool: true, skipPermission: true }], +]); + +function getClientToolSdkPolicy(toolName: string): IClientToolSdkPolicy { + return CLIENT_TOOL_SDK_POLICIES.get(toolName) ?? DEFAULT_CLIENT_TOOL_SDK_POLICY; +} + interface CopilotExitPlanModeResponse extends ExitPlanModeResult { readonly autoApproveEdits?: ExitPlanModeCompletedData['autoApproveEdits']; } @@ -1644,11 +1660,13 @@ export class CopilotAgentSession extends Disposable { const defer: 'auto' | 'never' | undefined = toolSearchActive ? (NON_DEFERRED_CLIENT_TOOL_NAMES.has(def.name) ? 'never' : 'auto') : undefined; + const sdkPolicy = getClientToolSdkPolicy(def.name); return { name: def.name, description: def.description ?? '', parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, defer, + ...sdkPolicy, handler: this._guarded(async (_args: Record<string, unknown>, { toolCallId }) => { try { return await this._pendingClientToolCalls.register(toolCallId); @@ -4323,9 +4341,10 @@ export class CopilotAgentSession extends Disposable { if (isToolSearch && clientToolAutoApproved) { meta.autoApproveBySetting = true; } + const sdkPolicy = getClientToolSdkPolicy(e.data.toolName); const shouldWaitForClientToolReady = contributor?.kind === ToolCallContributorKind.Client && !isAgentCoordinationTool(e.data.toolName) - && (isToolSearch || !clientToolAutoApproved); + && (isToolSearch || (!sdkPolicy.skipPermission && !clientToolAutoApproved)); if (shouldWaitForClientToolReady) { return; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 1a00daf1177500..a12ca576a699ff 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -19,6 +19,7 @@ import { CopilotCliConfigKey, copilotCliConfigSchema, normalizeModelFamilyAlias, import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { reasoningEffortLevels, type ReasoningEffortLevel } from '../../common/reasoningEffort.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; +import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import type { ModelSelection, ToolDefinition } from '../../common/state/protocol/state.js'; import { RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; import type { ActiveClientToolSet } from '../activeClientState.js'; @@ -780,14 +781,17 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { const availableTools = getToolFilterOverride(availableToolsOverride, 'availableTools', modelId, this._logService, plan.sessionId); const excludedTools = getToolFilterOverride(excludedToolsOverride, 'excludedTools', modelId, this._logService, plan.sessionId); const sdkAvailableTools = toSdkToolFilterPatterns(availableTools); - const sdkExcludedTools = plan.isEphemeral + const configuredSdkExcludedTools = plan.isEphemeral ? [...(toSdkToolFilterPatterns(excludedTools) ?? []), ...EPHEMERAL_DISABLED_COPILOT_TOOLS] : toSdkToolFilterPatterns(excludedTools); + const clientToolNames = filterClientToolNames(clientToolNamesFromSnapshot(plan.snapshot), availableTools, excludedTools); + const sdkExcludedTools = clientToolNames.has(SEMANTIC_SEARCH_TOOL_NAME) + ? configuredSdkExcludedTools + : [...new Set([...(configuredSdkExcludedTools ?? []), `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`])]; const modelCapabilitiesOverride = resolveModelCapabilityOverrideField(capabilityOverrides, model?.id, 'modelCapabilities', (value): value is Record<string, unknown> => isObject(value), () => { this._logService.warn(`[Copilot:${plan.sessionId}] Ignoring invalid 'modelCapabilities' capability override for '${modelId}'; expected an object`); }); const modelCapabilities = getModelCapabilitiesOverride(modelCapabilitiesOverride, modelId, this._logService, plan.sessionId); - const clientToolNames = filterClientToolNames(clientToolNamesFromSnapshot(plan.snapshot), availableTools, excludedTools); // Host-side routing only — the prompt contributor and the tool-search gate // below. The wire model stays the selected one, so the session still runs // on the real model with the aliased family's prompt and tool profile. diff --git a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts index 201933ef2cd0ee..d9426746d6b21f 100644 --- a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts +++ b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { isGpt56Model } from './modelIdentifiers.js'; +import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; export { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -15,6 +16,7 @@ export const NON_DEFERRED_CLIENT_TOOL_NAMES: ReadonlySet<string> = new Set<strin 'runTests', 'rename', 'usages', + SEMANTIC_SEARCH_TOOL_NAME, ]); /** Mirrors the Copilot extension's string-form `modelSupportsToolSearch`. */ diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 53e0e5df4a7b0e..688f6882ceb241 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -65,6 +65,7 @@ import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService import { IAgentHostReviewService, NULL_REVIEW_SERVICE } from '../../common/agentHostReviewService.js'; import { getCopilotHomePath } from '../../common/copilotHome.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import { join } from '../../../../base/common/path.js'; import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; @@ -7487,7 +7488,7 @@ suite('CopilotAgent', () => { // the per-model effort beats the picker's 'medium' reasoningEffort: 'xhigh', availableTools: ['builtin:*', 'mcp:*', 'custom:*'], - excludedTools: ['mcp:*', 'builtin:*', 'custom:*'], + excludedTools: ['mcp:*', 'builtin:*', 'custom:*', `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`], modelCapabilities: { supports: { vision: false } }, }); } finally { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index d1fc372337af5f..bccda799ef3fc5 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -58,6 +58,7 @@ import { IAgentConfigurationService } from '../../node/agentConfigurationService import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey } from '../../common/agentHostSchema.js'; import { CopilotCliConfigKey } from '../../common/copilotCliConfig.js'; +import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../common/sandboxConfigSchema.js'; import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; @@ -7997,6 +7998,75 @@ suite('CopilotAgentSession', () => { return toolSet; }; + test('semantic search overrides the built-in tool and is never deferred', async () => { + const semanticSearchSnapshot: IActiveClientSnapshot = { + tools: [{ + name: SEMANTIC_SEARCH_TOOL_NAME, + description: 'Semantically searches the workspace', + inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + }], + plugins: [], + mcpServers: {}, + }; + const { runtime } = await createAgentSession(disposables, { clientSnapshot: semanticSearchSnapshot }); + const [tool] = runtime.createClientSdkTools(true); + + assert.deepStrictEqual({ + name: tool.name, + defer: tool.defer, + overridesBuiltInTool: tool.overridesBuiltInTool, + skipPermission: tool.skipPermission, + }, { + name: SEMANTIC_SEARCH_TOOL_NAME, + defer: 'never', + overridesBuiltInTool: true, + skipPermission: true, + }); + }); + + test('semantic search becomes ready without an SDK permission callback', async () => { + const semanticSearchSnapshot: IActiveClientSnapshot = { + tools: [{ + name: SEMANTIC_SEARCH_TOOL_NAME, + description: 'Semantically searches the workspace', + inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + }], + plugins: [], + mcpServers: {}, + }; + const activeClientToolSet = new ActiveClientToolSet(); + activeClientToolSet.set('test-client', semanticSearchSnapshot.tools); + const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { + clientSnapshot: semanticSearchSnapshot, + activeClientToolSet, + }); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-semantic-search', + toolName: SEMANTIC_SEARCH_TOOL_NAME, + arguments: { query: 'tool routing' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + const readySignal = signals.find(s => isAction(s, ActionType.ChatToolCallReady)); + assert.ok(readySignal && isAction(readySignal, ActionType.ChatToolCallReady)); + const readyAction = readySignal.action as ChatToolCallReadyAction; + assert.deepStrictEqual({ + contributor: readyAction.contributor, + confirmed: readyAction.confirmed, + }, { + contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' }, + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const handlerPromise = invokeClientToolHandler(runtime.createClientSdkTools()[0], 'tc-semantic-search', { query: 'tool routing' }); + session.handleClientToolCallComplete('tc-semantic-search', { + success: true, + pastTenseMessage: 'Searched codebase', + content: [{ type: ToolResultContentType.Text, text: 'result text' }], + }); + assert.strictEqual((await handlerPromise).textResultForLlm, 'result text'); + }); + test('client tool started with no connected client fails immediately', async () => { // No activeClientState is provided, so the session seeds one with // an undefined clientId — i.e. no client is connected to run the tool. diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index d0e9fb7f7d730c..8ed8504bd9b3b1 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -21,6 +21,7 @@ import type { IAgentHostManagedSettingsPermissions } from '../../common/agentHos import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import type { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { reasoningEffortLevels } from '../../common/reasoningEffort.js'; +import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import { CustomizationType, McpServerStatus, type ModelSelection } from '../../common/state/protocol/state.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; @@ -518,7 +519,7 @@ suite('CopilotSessionLauncher shared session config', () => { resumeManagedSettings: { permissions: managedSettingsPermissions }, ephemeralMcpServers: {}, ephemeralDisabledMcpServers: ['azure', 'disabled-workspace-server', 'github', 'native-plugin-server', 'synced-server'], - ephemeralExcludedTools: ['task'], + ephemeralExcludedTools: ['task', `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`], }); } finally { sessions.dispose(); @@ -1013,6 +1014,24 @@ suite('CopilotSessionLauncher resume config', () => { return (launcher as unknown as { _buildSessionConfig(plan: unknown, runtime: unknown): Promise<{ model?: string; reasoningEffort?: string; contextTier?: string; availableTools?: string[]; excludedTools?: string[]; modelCapabilities?: Record<string, unknown>; toolSearch?: { enabled: boolean } }> })._buildSessionConfig(plan, runtime); } + test('exposes only the client semantic-search override', async () => { + const store = new DisposableStore(); + const snapshot = { tools: [{ name: SEMANTIC_SEARCH_TOOL_NAME }], plugins: [], mcpServers: {} }; + const disabled = await buildResumeConfig(createLauncher(store, {}), undefined, { tools: [], plugins: [], mcpServers: {} }); + const enabled = await buildResumeConfig(createLauncher(store, {}), undefined, snapshot); + const filtered = await buildResumeConfig( + createLauncher(store, { modelCapabilityOverrides: { '*': { excludedTools: [`custom:${SEMANTIC_SEARCH_TOOL_NAME}`] } } }), + undefined, + snapshot, + ); + + assert.deepStrictEqual( + [disabled.excludedTools, enabled.excludedTools, filtered.excludedTools], + [[`builtin:${SEMANTIC_SEARCH_TOOL_NAME}`], undefined, [`custom:${SEMANTIC_SEARCH_TOOL_NAME}`, `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`]], + ); + store.dispose(); + }); + test('forwards a configured override on resume and leaves the effort untouched otherwise', async () => { const store = new DisposableStore(); const model: ModelSelection = { id: 'gpt-5', config: { thinkingLevel: 'medium' } }; @@ -1054,7 +1073,7 @@ suite('CopilotSessionLauncher resume config', () => { assert.deepStrictEqual( [config.reasoningEffort, config.excludedTools], - ['high', ['mcp:*']] + ['high', ['mcp:*', `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`]] ); store.dispose(); }); @@ -1096,7 +1115,7 @@ suite('CopilotSessionLauncher resume config', () => { undefined, { availableTools: ['custom:*'], - excludedTools: ['mcp:*'], + excludedTools: ['mcp:*', `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`], modelCapabilities: { supports: { vision: true } }, }, undefined, @@ -1119,7 +1138,7 @@ suite('CopilotSessionLauncher resume config', () => { assert.deepStrictEqual( [config.availableTools, config.excludedTools], - [[RUNTIME_TOOL_SEARCH_TOOL_NAME], [`custom:${RUNTIME_TOOL_SEARCH_TOOL_NAME}`]] + [[RUNTIME_TOOL_SEARCH_TOOL_NAME], [`custom:${RUNTIME_TOOL_SEARCH_TOOL_NAME}`, `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`]] ); store.dispose(); }); 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 1ad13ce3f889f7..1a498bbc7a43e0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { coalesce } from '../../../../../../base/common/arrays.js'; import { DeferredPromise, Delayer } from '../../../../../../base/common/async.js'; import { onUnexpectedError } from '../../../../../../base/common/errors.js'; import { Event } from '../../../../../../base/common/event.js'; @@ -15,9 +16,12 @@ import { type IExtUri } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.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'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; import { createDecorator, IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { observableConfigValue } from '../../../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js'; @@ -29,7 +33,7 @@ import { IConfigurationResolverService } from '../../../../../services/configura import { AgentCustomizationSyncProvider } from './agentCustomizationSyncProvider.js'; import { type ILocalCustomizationSyncOptions, resolveCustomizationRefs, resolveLocalCustomAgents } from './agentHostLocalCustomizations.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; -import { IAgentHostToolSetEnablementService, isToolEnabledInSet } from './agentHostToolSetEnablementService.js'; +import { IAgentHostToolSetEnablementService, isCopilotCliSessionType, isToolEnabledInSet } from './agentHostToolSetEnablementService.js'; import { type ISyncedCustomizationOrigin, SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; export const IAgentHostActiveClientService = createDecorator<IAgentHostActiveClientService>('agentHostActiveClientService'); @@ -348,6 +352,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo private readonly _allToolsObs: IObservable<readonly IToolData[]>; private readonly _allToolSetsObs: IObservable<Iterable<IToolSet>>; + private readonly _semanticSearchEnabled: IObservable<boolean>; private readonly _clientToolsByType = new Map<string, IObservable<readonly ToolDefinition[]>>(); private readonly _registrationsByType = new Map<string, AgentRegistration>(); private _isDisposed = false; @@ -358,10 +363,12 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo @IInstantiationService private readonly _instantiationService: IInstantiationService, @IAgentHostToolSetEnablementService private readonly _toolSetEnablementService: IAgentHostToolSetEnablementService, @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); this._allToolsObs = this._toolsService.observeTools(undefined); this._allToolSetsObs = this._toolsService.toolSets; + this._semanticSearchEnabled = observableConfigValue(CopilotSemanticSearchEnabledSettingId, false, configurationService); } registerForAgent(sessionType: string, options?: IAgentRegistrationOptions): IAgentRegistration { @@ -403,6 +410,11 @@ 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 semanticSearchEnabled = isCopilotSession && this._semanticSearchEnabled.read(reader); + const semanticSearchTool = isCopilotSession + ? tools.find(tool => tool.id === CLIENT_SEMANTIC_SEARCH_TOOL_ID) + : undefined; const enabledToolIds = new Set<string>(); for (const ts of toolSets) { if (ts.deprecated) { @@ -414,7 +426,23 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo } } } - return tools.filter(t => enabledToolIds.has(t.id)).map(toolDataToDefinition); + return coalesce(tools.filter(tool => enabledToolIds.has(tool.id) || (semanticSearchEnabled && tool === semanticSearchTool)).map(tool => { + if (!isCopilotSession) { + return toolDataToDefinition(tool); + } + // Published under the SDK's built-in name so the session can override it. + if (tool === semanticSearchTool) { + return semanticSearchEnabled + ? { ...toolDataToDefinition(tool), name: SEMANTIC_SEARCH_TOOL_NAME } + : undefined; + } + // Nothing else may claim the published name: two client tools cannot + // share one SDK registration. + if (tool.toolReferenceName === CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME || tool.toolReferenceName === SEMANTIC_SEARCH_TOOL_NAME) { + return undefined; + } + return toolDataToDefinition(tool); + })); }); this._clientToolsByType.set(sessionType, obs); } 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 e56ef2ce849189..ab039beacfff6a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -37,6 +37,7 @@ import { readToolCallMeta } from '../../../../../../platform/agentHost/common/me import { readCompletionAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentCompletionAttachmentMeta.js'; import { IRemoteAgentHostService } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { CLIENT_SEMANTIC_SEARCH_TOOL_ID, SEMANTIC_SEARCH_TOOL_NAME } from '../../../../../../platform/agentHost/common/semanticSearchConstants.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../../../../../platform/agentHost/common/toolSearchConstants.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { IAgentSubscription, observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -89,7 +90,7 @@ import { ChatElicitationRequestPart } from '../../../common/model/chatProgressTy import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { getChatSessionType, isUntitledChatSession } from '../../../common/model/chatUri.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ILanguageModelToolsService, IToolResult, stringifyPromptTsxPart, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolResult, stringifyPromptTsxPart, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { IChatWidgetService } from '../../chat.js'; import { getAgentSessionProviderIcon } from '../agentSessions.js'; import { IAgentCustomizationScope, IAgentHostActiveClientService } from './agentHostActiveClientService.js'; @@ -104,6 +105,7 @@ 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, BOOLEAN_TRUE_OPTION_ID, completedToolCallToEditParts, completedToolCallToSerialized, containsAutomaticReplyAnswer, convertProtocolAnswers, convertProtocolPlanReviewResult, createInputRequestCarousel, createInputRequestPlanReview, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContent, getUrlInputRequestPresentation, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToRequestOrigin, messageToVariableData, parseAhpTerminalToolSessionId, rewriteAgentHostLinkTarget, shouldObserveSubagentChat, stringOrMarkdownToString, systemNotificationToChatPart, toolCallAuthenticationServer, toolCallStateToInvocation, toolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, turnsToHistory, updateRunningToolSpecificData, updateStreamingToolInvocation, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, type IAgentHostToolInvocationOptions, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; @@ -2499,6 +2501,16 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return invocation; } + /** 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); + if (isCopilotSession && toolName === SEMANTIC_SEARCH_TOOL_NAME) { + return this._toolsService.getTool(CLIENT_SEMANTIC_SEARCH_TOOL_ID); + } + const clientToolName = toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME ? CLIENT_TOOL_SEARCH_REFERENCE_NAME : toolName; + return this._toolsService.getToolByName(clientToolName); + } + /** * Whether an unclaimed client tool must wait for a rendering observer * before running. There is no protocol field for this, so we use the tool's @@ -2511,8 +2523,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * a claimed call always runs with context regardless. */ private _clientToolRequiresConfirmation(toolCall: ToolCallState): boolean { - const clientToolName = toolCall.toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME ? CLIENT_TOOL_SEARCH_REFERENCE_NAME : toolCall.toolName; - return this._toolsService.getToolByName(clientToolName)?.canRequestPreApproval === true; + return this._resolveClientTool(toolCall.toolName)?.canRequestPreApproval === true; } /** @@ -2530,8 +2541,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const toolCall = request.toolCall; const toolName = toolCall.toolName; const isToolSearch = toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME; - const clientToolName = isToolSearch ? CLIENT_TOOL_SEARCH_REFERENCE_NAME : toolName; - const toolData = this._toolsService.getToolByName(clientToolName); + const toolData = this._resolveClientTool(toolName); // A tool-search completion (success or failure) must drop the transient // candidate corpus from `_meta` while preserving any other metadata. @@ -3900,8 +3910,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC adopted.didExecuteTool(undefined); } - const clientToolName = toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME ? CLIENT_TOOL_SEARCH_REFERENCE_NAME : toolName; - const toolData = this._toolsService.getToolByName(clientToolName); + const toolData = this._resolveClientTool(toolName); if (!toolData) { this._logService.warn(`[AgentHost] Client tool call for unknown tool: ${toolName}`); this._dispatchAction(opts.backendSession, { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostToolSetEnablementService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostToolSetEnablementService.ts index 98393f76f248b8..839e6123273d02 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostToolSetEnablementService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostToolSetEnablementService.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { derived, IObservable, IReader, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { parseRemoteAgentHostHarness } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; @@ -17,6 +18,13 @@ export const IAgentHostToolSetEnablementService = createDecorator<IAgentHostTool */ export const AGENT_HOST_COPILOT_CLI_SESSION_TYPE = 'agent-host-copilotcli'; +/** + * Whether a session type runs the Copilot CLI harness, locally or on a remote agent host. + */ +export function isCopilotCliSessionType(sessionType: string): boolean { + return sessionType === AGENT_HOST_COPILOT_CLI_SESSION_TYPE || parseRemoteAgentHostHarness(sessionType) === 'copilotcli'; +} + /** * Tool / tool-set enablement state. Both maps are keyed by id and store only deviations from the * default ("enabled"). A tool's effective state resolves child → parent → default: 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 f3d4cc55a4aac3..2d0f60c8b62453 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -21,6 +21,7 @@ import '../../../../platform/agentHost/common/agentHostStarter.config.contributi import { AgentMergeSettingId } from '../../../../platform/agentHost/common/agentMerge.js'; import { AgentHostAhpJsonlLoggingSettingId, AgentHostAllowSignedOutWhenUsableSettingId, AgentHostSdkSandboxEnabledSettingId, AgentHostSdkSandboxWindowsEnabledSettingId, CodexPreferAgentHostEditorSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, copilotSdkLogLevelSettingValues } from '../../../../platform/agentHost/common/copilotCliConfig.js'; +import { CopilotSemanticSearchEnabledSettingId } from '../../../../platform/agentHost/common/semanticSearchConstants.js'; import { DEFAULT_EDIT_AUTO_APPROVE_PATTERNS, mergeChatEditAutoApprovePatterns } from '../../../../platform/chat/common/chatSettings.js'; import { reasoningEffortLevels } from '../../../../platform/agentHost/common/reasoningEffort.js'; import { ChatSessionArchiveActionWordingSettingId } from '../../../../platform/chat/common/sessionArchiveActions.js'; @@ -1588,6 +1589,12 @@ configurationRegistry.registerConfiguration({ minimum: 0, tags: ['experimental', 'advanced'], }, + [CopilotSemanticSearchEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.copilot.semanticSearch.enabled', "Controls whether Copilot Agent Host sessions can use VS Code's semantic workspace search. When disabled, semantic search is unavailable."), + default: false, + tags: ['experimental', 'advanced'], + }, [AgentHostReasoningEffortOverrideSettingId]: { type: 'string', markdownDescription: nls.localize('chat.agentHost.reasoningEffortOverride', "Overrides the reasoning effort for Copilot SDK agent sessions regardless of the per-model picker value. Set it to a level the selected model supports (for example `low`, `medium`, `high`, or `xhigh`) — choosing a level the model does not support may be rejected by the model. A value that isn't a recognized effort level is ignored and the session falls back to the picker value. Applied when a session is created and when its model changes. Only affects Copilot CLI agent sessions.\n\n**Note**: This is an advanced setting for experimentation."), 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 428a3604314d3c..40e7833d3b9b66 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 @@ -14,13 +14,14 @@ import { DisposableStore, IReference, toDisposable } from '../../../../../../bas import { ResourceSet } from '../../../../../../base/common/map.js'; import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { constObservable, observableValue, autorun } from '../../../../../../base/common/observable.js'; +import { constObservable, observableValue, autorun, type IObservable } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { AgentSession, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME, CLIENT_SEMANTIC_SEARCH_TOOL_ID, CopilotSemanticSearchEnabledSettingId, SEMANTIC_SEARCH_TOOL_NAME } from '../../../../../../platform/agentHost/common/semanticSearchConstants.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../../../../../platform/agentHost/common/toolSearchConstants.js'; import { isChatAction, isSessionAction, type ActionEnvelope, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, createChatState, createDefaultChatSummary, ChatInputResponseKind, MessageKind, SessionLifecycle, SessionStatus, createSessionState, StateComponents, parseDefaultChatUri, ToolCallCancellationReason, type ChatState, type SessionState, type SessionSummary, type RootState, type ToolInput } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -42,7 +43,7 @@ import { IConfigurationResolverService } from '../../../../../services/configura import { AgentHostSessionHandler, toolDataToDefinition, toolResultToProtocol, UNOBSERVED_CLIENT_TOOL_GRACE_MS } from '../../../browser/agentSessions/agentHost/agentHostSessionHandler.js'; import { AgentHostActiveClientService, IAgentHostActiveClientService } from '../../../browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IAgentHostCustomizationService, NullAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; -import { IAgentHostToolSetEnablementService, IToolEnablementState } from '../../../browser/agentSessions/agentHost/agentHostToolSetEnablementService.js'; +import { AGENT_HOST_COPILOT_CLI_SESSION_TYPE, IAgentHostToolSetEnablementService, IToolEnablementState } from '../../../browser/agentSessions/agentHost/agentHostToolSetEnablementService.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; @@ -56,7 +57,7 @@ import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHos import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostSessionWorkingDirectorySynchronizer } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectorySynchronizer.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; -import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, ToolAndToolSetEnablementMap, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, IToolSet, ToolAndToolSetEnablementMap, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { IChatWidgetService } from '../../../browser/chat.js'; import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; @@ -75,13 +76,21 @@ import { IUriIdentityService } from '../../../../../../platform/uriIdentity/comm 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 disposables = new DisposableStore(); teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); - test('shares a customization scope for equivalent root sets', async () => { + function createActiveClientService( + tools: IObservable<readonly IToolData[]> = constObservable([]), + toolSets: IObservable<Iterable<IToolSet>> = constObservable([]), + ) { const instantiationService = disposables.add(new TestInstantiationService()); + let semanticSearchEnabled = false; + const onDidChangeConfiguration = disposables.add(new Emitter<IConfigurationChangeEvent>()); instantiationService.stub(IFileService, TestFileService); instantiationService.stub(IAgentHostFileSystemService, { ensureSyncedCustomizationProvider: () => { }, @@ -91,8 +100,8 @@ suite('AgentHostClientTools', () => { override readonly extUri = extUriBiasedIgnorePathCase; }); instantiationService.stub(IConfigurationService, { - getValue: () => false, - onDidChangeConfiguration: Event.None, + getValue: (section: string) => section === CopilotSemanticSearchEnabledSettingId ? semanticSearchEnabled : false, + onDidChangeConfiguration: onDidChangeConfiguration.event, } as Partial<IConfigurationService> as IConfigurationService); instantiationService.stub(IConfigurationResolverService, {} as Partial<IConfigurationResolverService>); instantiationService.stub(IPromptsService, new class extends mock<IPromptsService>() { @@ -112,8 +121,8 @@ suite('AgentHostClientTools', () => { servers: observableValue('mcpServers', []), }); instantiationService.stub(ILanguageModelToolsService, { - observeTools: () => constObservable([]), - toolSets: constObservable([]), + observeTools: () => tools, + toolSets, } as Partial<ILanguageModelToolsService> as ILanguageModelToolsService); instantiationService.stub(IAgentHostToolSetEnablementService, { observe: () => constObservable<IToolEnablementState>({ toolSets: new Map(), tools: new Map() }), @@ -123,6 +132,21 @@ suite('AgentHostClientTools', () => { }); const service = disposables.add(instantiationService.createInstance(AgentHostActiveClientService)); + return { + service, + setSemanticSearchEnabled: (enabled: boolean) => { + semanticSearchEnabled = enabled; + onDidChangeConfiguration.fire(new class extends mock<IConfigurationChangeEvent>() { + override affectsConfiguration(section: string): boolean { + return section === CopilotSemanticSearchEnabledSettingId; + } + }); + }, + }; + } + + test('shares a customization scope for equivalent root sets', async () => { + const { service } = createActiveClientService(); const registration = disposables.add(service.registerForAgent('agent-host-claude')); const rootA = URI.file('/Workspace-A'); const rootB = URI.file('/Workspace-B'); @@ -157,6 +181,70 @@ suite('AgentHostClientTools', () => { }); }); + const semanticSearchTool: IToolData = { + id: CLIENT_SEMANTIC_SEARCH_TOOL_ID, + toolReferenceName: CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME, + displayName: 'Search Codebase', + modelDescription: 'Semantically searches the workspace', + source: ToolDataSource.Internal, + inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + }; + const collidingCodebaseTool: IToolData = { + ...semanticSearchTool, + id: 'other.codebase', + displayName: 'Other Codebase', + canRequestPreApproval: true, + }; + const collidingSemanticSearchTool: IToolData = { + ...semanticSearchTool, + id: 'other.semanticSearch', + toolReferenceName: SEMANTIC_SEARCH_TOOL_NAME, + displayName: 'Other Semantic Search', + }; + const readFileTool: IToolData = { + id: 'vscode.readFile', + toolReferenceName: 'readFile', + displayName: 'Read File', + modelDescription: 'Reads a file', + source: ToolDataSource.Internal, + }; + + async function publishedTools(tools: readonly IToolData[], sessionType: string, enabled: boolean) { + const searchToolSet = new class extends mock<IToolSet>() { + override readonly id = 'search'; + override readonly deprecated = true; + override getTools(): Iterable<IToolData> { return tools.filter(tool => tool.id === CLIENT_SEMANTIC_SEARCH_TOOL_ID); } + }; + const enabledToolSet = new class extends mock<IToolSet>() { + override readonly id = 'enabled'; + override readonly deprecated = false; + override getTools(): Iterable<IToolData> { return tools.filter(tool => tool.id !== CLIENT_SEMANTIC_SEARCH_TOOL_ID); } + }; + const client = createActiveClientService(constObservable(tools), constObservable([searchToolSet, enabledToolSet])); + const registration = disposables.add(client.service.registerForAgent(sessionType)); + const scope = disposables.add(registration.acquireScope([])); + await scope.whenResolved(); + client.setSemanticSearchEnabled(enabled); + return scope.tools.get().map(tool => [tool.name, tool.title]); + } + + test('gates and reserves semantic search for Copilot sessions', async () => { + const tools = [collidingCodebaseTool, collidingSemanticSearchTool, semanticSearchTool, readFileTool]; + assert.deepStrictEqual({ + 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), + 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']], + otherEnabled: [[CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME, 'Other Codebase'], [SEMANTIC_SEARCH_TOOL_NAME, 'Other Semantic Search'], ['readFile', 'Read File']], + withoutCanonical: [['readFile', 'Read File']], + }); + }); + // ── toolDataToDefinition ───────────────────────────────────────────── suite('toolDataToDefinition', () => { @@ -622,6 +710,7 @@ suite('AgentHostClientTools', () => { disposables: DisposableStore, tools: IToolData[], toolServiceOptions?: { requireConfirmation?: boolean; throwBeforeConfirmation?: Error; invokeResult?: DeferredPromise<IToolResult> }, + sessionType: string = AGENT_HOST_COPILOT_CLI_SESSION_TYPE, ) { const instantiationService = disposables.add(new TestInstantiationService()); const connection = new MockAgentHostConnection(); @@ -750,7 +839,7 @@ suite('AgentHostClientTools', () => { const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, agentId: 'agent-host-copilot', - sessionType: 'agent-host-copilot', + sessionType, fullName: 'Test', description: 'Test', connection, @@ -2372,6 +2461,112 @@ 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 codebaseTool = isCopilot + ? semanticSearchTool + : { ...semanticSearchTool, canRequestPreApproval: true }; + const { handler, connection, toolsService } = createHandlerWithMocks( + disposables, + [collidingCodebaseTool, codebaseTool, collidingSemanticSearchTool], + undefined, + sessionType, + ); + const sessionResource = URI.from({ scheme: sessionType, path: '/session-1' }); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + + connection.applySessionAction(URI.parse(backendSession), { + type: ActionType.SessionInputNeededSet, + request: { + id: `execution-${toolCallId}`, + kind: SessionInputRequestKind.ToolClientExecution, + chat: buildSubagentChatUri(backendSession, `task-${toolCallId}`), + turnId: `turn-${toolCallId}`, + clientId: connection.clientId, + toolCall: { + status: ToolCallStatus.Running, + toolCallId, + toolName: SEMANTIC_SEARCH_TOOL_NAME, + displayName: 'Semantic Search', + invocationMessage: 'Searching', + toolInput: '{"query":"tool routing"}', + confirmed: ToolCallConfirmationReason.NotNeeded, + contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, + }, + }, + }); + await timeout(0); + + return toolsService.invokedToolCalls[0]?.toolId; + }; + + assert.deepStrictEqual( + [ + await invoke(AGENT_HOST_COPILOT_CLI_SESSION_TYPE, 'copilot-semantic'), + await invoke(REMOTE_COPILOT_CLI_SESSION_TYPE, 'remote-copilot-semantic'), + await invoke('agent-host-claude', 'claude-semantic'), + ], + [semanticSearchTool.id, semanticSearchTool.id, collidingSemanticSearchTool.id], + ); + })); + + test('renders non-Copilot semantic search as the exact client tool', async () => { + const sessionType = 'agent-host-claude'; + const { handler, connection, toolsService } = createHandlerWithMocks( + disposables, + [semanticSearchTool, collidingSemanticSearchTool], + undefined, + sessionType, + ); + const sessionResource = URI.from({ scheme: sessionType, path: '/session-1' }); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + const chatURI = URI.parse(buildDefaultChatUri(backendSession)); + + connection.applySessionAction(chatURI, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-semantic', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'search', origin: { kind: MessageKind.User } }, + }); + connection.applySessionAction(chatURI, { + type: ActionType.ChatToolCallStart, + turnId: 'turn-semantic', + toolCallId: 'semantic-call', + toolName: SEMANTIC_SEARCH_TOOL_NAME, + displayName: 'Semantic Search', + contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, + }); + connection.applySessionAction(chatURI, { + type: ActionType.ChatToolCallReady, + turnId: 'turn-semantic', + toolCallId: 'semantic-call', + invocationMessage: 'Searching', + toolInput: '{"query":"tool routing"}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + applyRunningClientExecution(connection, chatURI.toString(), 'turn-semantic', { + toolCallId: 'semantic-call', + toolName: SEMANTIC_SEARCH_TOOL_NAME, + displayName: 'Semantic Search', + invocationMessage: 'Searching', + toolInput: '{"query":"tool routing"}', + }); + await timeout(0); + await timeout(0); + + assert.deepStrictEqual({ + begun: toolsService.begunToolCalls[0]?.toolId, + invoked: toolsService.invokedToolCalls[0]?.toolId, + }, { + begun: collidingSemanticSearchTool.id, + invoked: collidingSemanticSearchTool.id, + }); + }); + test('executes a claimed client tool exactly once, with chat context', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { handler, connection, toolsService } = createHandlerWithMocks(disposables, [testRunTaskTool]); const sessionResource = URI.parse('agent-host-copilot:/session-1'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostToolSetEnablementService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostToolSetEnablementService.test.ts index 94da5189647e0e..7d4f1b9355b233 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostToolSetEnablementService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostToolSetEnablementService.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; -import { AgentHostToolSetEnablementService, AGENT_HOST_COPILOT_CLI_SESSION_TYPE, countEnabledCustomizationTools, getToolSetTriState, isToolEnabledInSet } from '../../../browser/agentSessions/agentHost/agentHostToolSetEnablementService.js'; +import { AgentHostToolSetEnablementService, AGENT_HOST_COPILOT_CLI_SESSION_TYPE, countEnabledCustomizationTools, getToolSetTriState, isCopilotCliSessionType, isToolEnabledInSet } from '../../../browser/agentSessions/agentHost/agentHostToolSetEnablementService.js'; suite('AgentHostToolSetEnablementService', () => { @@ -20,6 +20,18 @@ suite('AgentHostToolSetEnablementService', () => { return { storageService, sut: store.add(new AgentHostToolSetEnablementService(storageService)) }; } + test('isCopilotCliSessionType matches local and remote Copilot CLI harnesses', () => { + assert.deepStrictEqual( + [ + AGENT_HOST_COPILOT_CLI_SESSION_TYPE, + 'remote-devbox-copilotcli', + 'remote-my-dash-box-copilotcli', + 'remote-devbox-claude', + 'agent-host-claude', + ].map(isCopilotCliSessionType), + [true, true, true, false, false]); + }); + test('default state: everything enabled, set tri-state on', () => { const { sut } = createSut(); const state = sut.getState(SESSION); From 18a568d5beb038e5d148613e82b0c37a81cc6470 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura <dmitriv@microsoft.com> Date: Thu, 20 Aug 2026 19:33:12 -0700 Subject: [PATCH 12/15] Defer dynamic model setting schemas until after restore (#331891) Defer dynamic model setting schemas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/exploreAgentDefaultModel.ts | 2 +- .../workbench/contrib/chat/browser/planAgentDefaultModel.ts | 2 +- .../contrib/chat/browser/utilityModelContribution.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/exploreAgentDefaultModel.ts b/src/vs/workbench/contrib/chat/browser/exploreAgentDefaultModel.ts index b20d98e421564b..5f142f0c8b45be 100644 --- a/src/vs/workbench/contrib/chat/browser/exploreAgentDefaultModel.ts +++ b/src/vs/workbench/contrib/chat/browser/exploreAgentDefaultModel.ts @@ -31,4 +31,4 @@ export class ExploreAgentDefaultModel extends DefaultModelContribution { } } -registerWorkbenchContribution2(ExploreAgentDefaultModel.ID, ExploreAgentDefaultModel, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ExploreAgentDefaultModel.ID, ExploreAgentDefaultModel, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts b/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts index e6a8cd319438b0..34b1480abc4013 100644 --- a/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts +++ b/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts @@ -31,4 +31,4 @@ export class PlanAgentDefaultModel extends DefaultModelContribution { } } -registerWorkbenchContribution2(PlanAgentDefaultModel.ID, PlanAgentDefaultModel, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(PlanAgentDefaultModel.ID, PlanAgentDefaultModel, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts b/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts index 821a7203cccbb2..118d5fd5f0778f 100644 --- a/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts @@ -75,5 +75,5 @@ export class UtilitySmallModelContribution extends DefaultModelContribution { } } -registerWorkbenchContribution2(UtilityModelContribution.ID, UtilityModelContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(UtilitySmallModelContribution.ID, UtilitySmallModelContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(UtilityModelContribution.ID, UtilityModelContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(UtilitySmallModelContribution.ID, UtilitySmallModelContribution, WorkbenchPhase.AfterRestored); From ec8a43f15d6152c7e59df8edf9288cd6c42b9d8b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:33:26 +0000 Subject: [PATCH 13/15] Only swap Alt-hold close action for the hovered tab in MultiEditorTabsControl (#331772) * Initial plan * Only swap Alt-hold close action for the hovered tab Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --- .../parts/editor/multiEditorTabsControl.ts | 79 +++++++++++++++---- 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index eb84da5e5a3c8f..f180d9ea760275 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -120,8 +120,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { private readonly unpinEditorAction = this._register(this.instantiationService.createInstance(UnpinEditorAction, UnpinEditorAction.ID, UnpinEditorAction.LABEL)); private readonly closeOtherEditorTabsInGroupAction = this._register(this.instantiationService.createInstance(CloseOtherEditorTabsInGroupAction, CloseOtherEditorTabsInGroupAction.ID, CloseOtherEditorTabsInGroupAction.LABEL)); - // Alt-hold alternative to a tab's close action (JetBrains-style); see updateTabActionsForAltState(). - private wantsCloseOthersAction: boolean; + // Alt-hold alternative to a tab's close action (JetBrains-style), applied only + // to the currently hovered tab; see updateTabActionForHoveredTab(). + private hoveredTabIndex: number | undefined; private readonly tabResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); private tabLabels: IEditorInputLabel[] = []; @@ -175,24 +176,47 @@ export class MultiEditorTabsControl extends EditorTabsControl { // React to decorations changing for our resource labels this._register(this.tabResourceLabels.onDidChangeDecorations(() => this.doHandleDecorationsChange())); - // React to Alt being held/released to swap in the "Close Others" tab action. Initialize - // from the current state too, in case this control is created mid-hold. - this.wantsCloseOthersAction = modifierKeyEmitter.keyStatus.altKey; - this._register(modifierKeyEmitter.event(() => this.updateTabActionsForAltState())); + // React to Alt being held/released to swap in the "Close Others" tab action + // for the currently hovered tab only (if any). + this._register(modifierKeyEmitter.event(() => this.updateTabActionForHoveredTab())); } - private updateTabActionsForAltState(): void { - const wantsCloseOthersAction = modifierKeyEmitter.keyStatus.altKey; - if (wantsCloseOthersAction === this.wantsCloseOthersAction) { + private updateTabActionForHoveredTab(): void { + if (typeof this.hoveredTabIndex !== 'number') { + return; // no tab hovered, nothing to update + } + + this.redrawTabActionAtIndex(this.hoveredTabIndex); + } + + private redrawTabActionAtIndex(tabIndex: number): void { + const editor = this.tabsModel.getEditorByIndex(tabIndex); + if (editor) { + this.doWithTab(tabIndex, editor, (editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar) => this.redrawTabAction(editor, tabIndex, tabContainer, tabActionBar)); + } + } + + // Tracks which tab (if any) the mouse is currently over so the Alt-hold "Close + // Others" swap (see redrawTabAction()) applies only to that single tab. + private setHoveredTab(tabIndex: number | undefined): void { + if (this.hoveredTabIndex === tabIndex) { return; } - this.wantsCloseOthersAction = wantsCloseOthersAction; + const previousHoveredTabIndex = this.hoveredTabIndex; + this.hoveredTabIndex = tabIndex; - // Only the action items need to change here, not labels/decorations/toolbar/layout. - this.forEachTab((editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar) => { - this.redrawTabAction(editor, tabIndex, tabContainer, tabActionBar); - }); + if (!modifierKeyEmitter.keyStatus.altKey) { + return; // Alt is not held, no action swap in effect to redraw + } + + if (typeof previousHoveredTabIndex === 'number') { + this.redrawTabActionAtIndex(previousHoveredTabIndex); + } + + if (typeof tabIndex === 'number') { + this.redrawTabActionAtIndex(tabIndex); + } } protected override create(parent: HTMLElement): HTMLElement { @@ -397,6 +421,11 @@ export class MultiEditorTabsControl extends EditorTabsControl { } })); + // Clear the hovered tab once the mouse leaves the tabs container entirely + this._register(addDisposableListener(tabsContainer, EventType.MOUSE_LEAVE, () => { + this.setHoveredTab(undefined); + })); + // Prevent auto-pasting (https://github.com/microsoft/vscode/issues/201696) if (isLinux) { this._register(addDisposableListener(tabsContainer, EventType.MOUSE_UP, e => { @@ -666,6 +695,12 @@ export class MultiEditorTabsControl extends EditorTabsControl { private handleClosedEditors(): void { + // A stale hovered tab index could otherwise leave a tab + // showing "Close Others" after the tabs it pointed past got removed + if (typeof this.hoveredTabIndex === 'number' && this.hoveredTabIndex >= this.tabsModel.count) { + this.setHoveredTab(undefined); + } + // There are tabs to show if (this.tabsModel.count) { @@ -1023,6 +1058,17 @@ export class MultiEditorTabsControl extends EditorTabsControl { disposables.add(addDisposableListener(tab, EventType.MOUSE_DOWN, e => handleClickOrTouch(e, false))); disposables.add(addDisposableListener(tab, TouchEventType.Tap, (e: GestureEvent) => handleClickOrTouch(e, true))); // Preserve focus on touch #125470 + // Track hover so the Alt-hold "Close Others" action swap (see redrawTabAction()) + // only applies to the tab the mouse is currently over. + disposables.add(addDisposableListener(tab, EventType.MOUSE_ENTER, () => { + this.setHoveredTab(tabIndex); + })); + disposables.add(addDisposableListener(tab, EventType.MOUSE_LEAVE, () => { + if (this.hoveredTabIndex === tabIndex) { + this.setHoveredTab(undefined); + } + })); + // Touch Scroll Support disposables.add(addDisposableListener(tab, TouchEventType.Change, (e: GestureEvent) => { tabsScrollbar.setScrollPosition({ scrollLeft: tabsScrollbar.getScrollPosition().scrollLeft - e.translationX }); @@ -1622,8 +1668,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { const hasCloseAction = isCloseable && !hasUnpinAction && options.tabActionCloseVisibility; const hasAction = hasUnpinAction || hasCloseAction; - // Alt swaps a visible Close action to Close Others; Unpin is unaffected. - const wantsCloseOthersAction = hasCloseAction && this.wantsCloseOthersAction; + // Alt swaps a visible Close action to Close Others, but only for the + // currently hovered tab; Unpin is unaffected. + const wantsCloseOthersAction = hasCloseAction && modifierKeyEmitter.keyStatus.altKey && tabIndex === this.hoveredTabIndex; this.closeOtherEditorTabsInGroupAction.enabled = this.groupView.count > 1; let tabAction; From 584b7e3e1e25a98b221d8dbde1d72e9ae2a0e8af Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:33:59 -0700 Subject: [PATCH 14/15] chat: fix Find reporting matches it cannot reveal, and navigate newest first (#331723) * Agent Host changes for osortega/agents/review-chatfindmodel-widget-editor * chat: index only the text a response renders in Find Find counted matches that had no DOM node to land on, so the result count overstated the total and navigation walked past positions it could never reach. Three separate causes, each fixed where the text is indexed rather than compensated for during navigation: - Markdown link targets were indexed. `renderAsPlaintext` emitted a list item's raw source instead of parsing its tokens, so a link kept its target: a response listing its edits as `[src/](/some/path)` indexed the path, of which only the label renders. Adds an opt-in `parseListItemTokens` so the 90+ existing callers are unaffected. - Filtered responses were indexed. The renderer drops the references slot, the body and the citations for a filtered response, keeping only the error message, which also shifted every predicted part index for the row. - Parts merged into one block were fused. Plaintext is trimmed per part and then concatenated, so `See ` + `foo.ts` + ` for details` was indexed as `Seefoo.tsfor details`, hiding text that is plainly on screen. Removes `dropActiveMatch`, which existed to correct the count after the fact and made the total change as the user navigated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: name the plaintext option for what it guarantees `parseListItemTokens` described marked's data model rather than the output, and named only the case that surfaced the bug: the option also reduces bold, emphasis and code spans, and swaps the block-level text renderer. `omitMarkdownSyntax` states what the caller gets, so a future leak fixed in another renderer folds in without a second option. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: address Find review feedback - Only schedule a debounced search when the query or options differ from what the model last searched. An option toggle reaches `_onInputChanged` after the base state listener has already run `findFirst`, so the second, identical search left Enter flushing it instead of advancing a match. - Keep a segment's *last* `limit` matches. The per-segment cap was applied before the array was reversed, so a single over-limit segment retained its oldest occurrences and dropped the newest ones navigation reaches first. - Yield once before the result count snapshots state. `FindInput.onDidChange` fires before the `onInput` handler that schedules the search, so the waiter saw nothing pending and reported the previous query's matches. - Condense multi-line method-body comments to one line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: separate a nested list from the item holding it Verified `renderAsPlaintext` against the vendored marked and found two things wrong with the list-item change. Parsing a tight item's tokens with `top = false` skips the paragraph treatment that gives block content a boundary, so an item holding a nested list ran straight into it: `- outer\n - inner [link](/t)` produced `outerinner link`. Parsing as top-level restores the break and also drops a redundant blank line from loose lists. The two new assertions expected a single newline between list items where marked emits two; `renderer.list` joins items that already end in one. Default output is unchanged in every case, including the pre-existing plaintext suite. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/browser/markdownRenderer.ts | 32 +++ .../test/browser/markdownRenderer.test.ts | 20 ++ .../chatFind/chatFindAccessibilityHelp.ts | 2 +- .../widget/chatFind/chatFindContent.ts | 37 ++- .../browser/widget/chatFind/chatFindModel.ts | 84 +++++- .../browser/widget/chatFind/chatFindWidget.ts | 256 ++++++++++++++++-- .../chat/browser/widget/chatListWidget.ts | 21 ++ .../contrib/chat/browser/widget/chatWidget.ts | 4 + .../test/browser/widget/chatFindModel.test.ts | 196 +++++++++++++- .../browser/widget/chatFindWidget.test.ts | 176 +++++++++++- 10 files changed, 780 insertions(+), 48 deletions(-) diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index 4a9ce27be503f3..b106d3fdef11ee 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -702,6 +702,16 @@ export function renderAsPlaintext(str: IMarkdownString | string, options?: { readonly includeCodeBlocksFences?: boolean; /** Controls if we want to format empty links from "Link [](file)" to "Link file" */ readonly useLinkFormatter?: boolean; + /** + * Controls whether markdown syntax is reduced to its text everywhere, rather than only where + * the renderer already does so. + * + * By default a list item is emitted as its raw source, so inline syntax survives into the + * output — a link keeps its target, as in `- Added [src/](/some/path)`, and `**bold**` keeps + * its asterisks. Enable this for callers that need the text a reader actually sees. Off by + * default because it changes long-standing output for every caller. + */ + readonly omitMarkdownSyntax?: boolean; }) { if (typeof str === 'string') { return str; @@ -720,6 +730,12 @@ export function renderAsPlaintext(str: IMarkdownString | string, options?: { if (options?.useLinkFormatter) { renderer.link = linkFormatter; } + if (options?.omitMarkdownSyntax) { + renderer.listitem = parsedListItem; + // A tight list item's content arrives as a block-level text token carrying the inline + // tokens, so the list item alone is not enough to reach the inline renderers. + renderer.text = parsedText; + } const html = marked.parse(value, { async: false, renderer }); return sanitizeRenderedMarkdown(html, { isTrusted: false }, {}) @@ -816,6 +832,22 @@ const linkFormatter = ({ text, href }: marked.Tokens.Link): string => { return text; }; +/** + * Renders a list item from its parsed tokens rather than its raw source, so inline markdown is + * reduced to text the way it already is in a paragraph. Opt-in via `omitMarkdownSyntax`. + * + * Parses as top-level so a tight item's text becomes a paragraph: without that boundary an item + * holding a nested list would run straight into it, as in `outerinner link`. + */ +const parsedListItem = function (this: marked.Renderer, { tokens }: marked.Tokens.ListItem): string { + return this.parser.parse(tokens, true); +}; + +/** Renders a block-level text token through its inline tokens. Opt-in via `omitMarkdownSyntax`. */ +const parsedText = function (this: marked.Renderer, token: marked.Tokens.Text): string { + return token.tokens ? this.parser.parseInline(token.tokens) : token.text; +}; + function mergeRawTokenText(tokens: marked.Token[]): string { let mergedTokenText = ''; tokens.forEach(token => { diff --git a/src/vs/base/test/browser/markdownRenderer.test.ts b/src/vs/base/test/browser/markdownRenderer.test.ts index 12c9b5c0fd87ab..dad302039724b6 100644 --- a/src/vs/base/test/browser/markdownRenderer.test.ts +++ b/src/vs/base/test/browser/markdownRenderer.test.ts @@ -460,6 +460,26 @@ suite('MarkdownRenderer', () => { assert.strictEqual(renderAsPlaintext({ value: 'Run `tests & build`' }), 'Run tests & build'); assert.strictEqual(renderAsPlaintext({ value: 'Use `<form>` tag' }), 'Use <form> tag'); }); + + test('reduces inline syntax inside list items when omitMarkdownSyntax is set', () => { + // A list item's content arrives as a text token carrying inline tokens. By default the + // item is emitted as raw source, so a link keeps its target; opting in reduces it to + // the text a reader actually sees. + const markdown = { value: '- Added [src/](/some/path/to/src)\n- Uses **bold** and `code`' }; + + assert.strictEqual( + renderAsPlaintext(markdown), + 'Added [src/](/some/path/to/src)\n\nUses **bold** and `code`', + 'default output is unchanged'); + assert.strictEqual( + renderAsPlaintext(markdown, { omitMarkdownSyntax: true }), + 'Added src/\n\nUses bold and code'); + }); + + test('separates a nested list from the item holding it when omitMarkdownSyntax is set', () => { + const markdown = { value: '- outer\n - inner [link](/target)' }; + assert.strictEqual(renderAsPlaintext(markdown, { omitMarkdownSyntax: true }), 'outer\ninner link'); + }); }); suite('supportHtml', () => { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindAccessibilityHelp.ts index cf2bbc12cd3c8b..e6ef293d433a34 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindAccessibilityHelp.ts @@ -41,7 +41,7 @@ class ChatFindAccessibilityHelpProvider extends Disposable implements IAccessibl provideContent(): string { const content: string[] = []; content.push(localize('chatFind.header', "Accessibility Help: Chat Transcript Find")); - content.push(localize('chatFind.context', "You are in the Find input for the chat transcript. It searches the whole conversation, including turns that are scrolled out of view, not only what is on screen.")); + content.push(localize('chatFind.context', "You are in the Find input for the chat transcript. It searches the whole conversation, including turns that are scrolled out of view, not only what is on screen. Matches are ordered newest first, starting from the turn you are looking at and working back through earlier ones.")); content.push(''); content.push(localize('chatFind.keyboardHeader', "Keyboard Navigation Summary:")); content.push(localize('chatFind.keyEnter', "- Enter: Move to the next match.")); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindContent.ts b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindContent.ts index ddcf9f21839ada..9d8eb5582cca4f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindContent.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindContent.ts @@ -48,6 +48,27 @@ function isErrorDetailsRendered(item: IChatResponseViewModel): boolean { && errorDetails.message !== canceledName; } +/** + * Plaintext options for indexing. `omitMarkdownSyntax` matters because a response commonly lists + * its edits as markdown links, and only the label is rendered: without it the link target is + * indexed too, producing matches that exist in no DOM node and so can never be revealed. + */ +const FIND_PLAINTEXT_OPTIONS = { omitMarkdownSyntax: true } as const; + +/** + * Restores the whitespace `renderAsPlaintext` trims off the ends of a part. + * + * Parts that the renderer merges into a single block are concatenated when indexed, so a trimmed + * boundary fuses the tail of one part onto the head of the next: `See ` + `foo.ts` + ` for details` + * would be indexed as `Seefoo.tsfor details`, which matches nothing on screen and hides the text + * that is really there. A newline is restored where the source had one, matching how the DOM side + * separates blocks; otherwise a single space. + */ +function withSourceEdgeWhitespace(source: string, text: string): string { + const edge = (whitespace: string | undefined) => whitespace ? (whitespace.includes('\n') ? '\n' : ' ') : ''; + return edge(/^\s+/.exec(source)?.[0]) + text + edge(/\s+$/.exec(source)?.[0]); +} + /** * Extracts the text a response actually *renders*, for Find to index. * @@ -66,7 +87,13 @@ export function getChatFindTextParts(item: IChatResponseViewModel): IChatFindTex if (isErrorDetailsRendered(item)) { // The message is rendered as markdown, so index its plaintext form rather than the raw // source; otherwise syntax characters would be counted but absent from the DOM. - parts.push({ partIndex: -1, text: renderAsPlaintext(new MarkdownString(item.errorDetails!.message)) }); + parts.push({ partIndex: -1, text: renderAsPlaintext(new MarkdownString(item.errorDetails!.message), FIND_PLAINTEXT_OPTIONS) }); + } + + if (item.errorDetails?.responseIsFiltered) { + // A filtered response renders none of its content: the renderer drops the references + // slot, the response body and the code citations, leaving only the error message above. + return parts; } item.response.value.forEach((part, partIndex) => { @@ -75,9 +102,9 @@ export function getChatFindTextParts(item: IChatResponseViewModel): IChatFindTex // No code fences or link formatting: the ``` markers are consumed by the code // block, and an empty link renders as an empty anchor, so both would contribute // text that is counted here but absent from the DOM. - const text = renderAsPlaintext(part.content); + const text = renderAsPlaintext(part.content, FIND_PLAINTEXT_OPTIONS); if (text.trim()) { - parts.push({ partIndex, text }); + parts.push({ partIndex, text: withSourceEdgeWhitespace(part.content.value, text) }); } break; } @@ -100,8 +127,8 @@ export function getChatFindTextParts(item: IChatResponseViewModel): IChatFindTex } case 'elicitation2': case 'elicitationSerialized': { - const title = isMarkdownString(part.title) ? renderAsPlaintext(part.title) : part.title; - const message = isMarkdownString(part.message) ? renderAsPlaintext(part.message) : part.message; + const title = isMarkdownString(part.title) ? renderAsPlaintext(part.title, FIND_PLAINTEXT_OPTIONS) : part.title; + const message = isMarkdownString(part.message) ? renderAsPlaintext(part.message, FIND_PLAINTEXT_OPTIONS) : part.message; const text = [title, message].filter(value => typeof value === 'string' && value.trim()).join('\n'); if (text.trim()) { parts.push({ partIndex, text }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindModel.ts b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindModel.ts index 8ed01325e92e1d..eeb8d7667fa1ea 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindModel.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindModel.ts @@ -13,7 +13,7 @@ import { annotateSpecialMarkdownContentWithSource } from '../../../common/widget import { moveResponseOutcomeToolsAfterFinalResponse } from '../chatListRenderer.js'; /** Upper bound on tracked matches, mirroring `LIMIT_FIND_COUNT` in `textModelSearch.ts`, so a pathological regex can't pin the UI. */ -const MAX_FIND_MATCHES = 9999; +export const MAX_FIND_MATCHES = 9999; export interface IChatFindOptions { readonly isRegex: boolean; @@ -64,13 +64,15 @@ function buildSegments(items: readonly ChatTreeItem[]): IChatFindSegment[] { segments.push({ itemId: item.id, itemKind: 'request', partIndex: -1, text: item.messageText }); } } else if (isResponseVM(item)) { - const annotated = annotateSpecialMarkdownContentWithSource(item.response.value); + // A filtered response renders no content at all, so row-level text starts at 0. + const isFiltered = !!item.errorDetails?.responseIsFiltered; + const annotated = isFiltered ? [] : annotateSpecialMarkdownContentWithSource(item.response.value); const renderedContent = item.isComplete ? moveResponseOutcomeToolsAfterFinalResponse(annotated.map(entry => entry.content)) : annotated.map(entry => entry.content); // Mirrors the renderer, which puts the references slot first and code citations // between the response content and the trailing parts that hold row-level text. - const trailingPartIndex = renderedContent.length + 1 + (item.codeCitations?.length ? 1 : 0); + const trailingPartIndex = isFiltered ? 0 : renderedContent.length + 1 + (item.codeCitations?.length ? 1 : 0); // Indexes only what the response renders: `getChatFindTextParts` deliberately omits // reasoning and tool result payloads, whose text does not exist in the DOM until // their container is expanded, so a match there could never be revealed. @@ -106,13 +108,22 @@ function buildSegments(items: readonly ChatTreeItem[]): IChatFindSegment[] { return segments; } +/** + * Bounds a single segment's scan. Higher than {@link MAX_FIND_MATCHES} because a segment has to be + * scanned past the cap to know which of its occurrences are the newest, but still finite so a + * pathological regex over a huge response cannot pin the UI. + */ +const MAX_SEGMENT_SCAN = 100_000; + +/** The segment's last `limit` matches, since navigation visits a segment's newest occurrence first. */ function findMatchesInSegment(segment: IChatFindSegment, regex: RegExp, limit: number): IChatFindMatch[] { + if (limit <= 0) { + return []; + } const matches: IChatFindMatch[] = []; regex.lastIndex = 0; let occurrenceIndex = 0; let match: RegExpExecArray | null; - // Guard against catastrophic/zero-length-match regexes looping forever. - let safety = 0; while ((match = regex.exec(segment.text))) { matches.push({ itemId: segment.itemId, @@ -128,11 +139,15 @@ function findMatchesInSegment(segment: IChatFindSegment, regex: RegExp, limit: n if (match[0].length === 0) { regex.lastIndex++; } - if (++safety > MAX_FIND_MATCHES || matches.length >= limit) { + // Trimming at twice the limit bounds the window without shifting on every match. + if (matches.length >= limit * 2) { + matches.splice(0, matches.length - limit); + } + if (occurrenceIndex >= MAX_SEGMENT_SCAN) { break; } } - return matches; + return matches.length > limit ? matches.slice(-limit) : matches; } /** Searches the logical chat transcript independently of rendered rows. */ @@ -149,7 +164,8 @@ export class ChatFindModel extends Disposable { private _invalidRegex = false; constructor( - private readonly getItems: () => readonly ChatTreeItem[] + private readonly getItems: () => readonly ChatTreeItem[], + private readonly getViewportAnchorItemId: () => string | undefined = () => undefined ) { super(); } @@ -180,8 +196,16 @@ export class ChatFindModel extends Disposable { } setQuery(query: string, options: IChatFindOptions): void { + const changed = query !== this._query + || options.isRegex !== this._options.isRegex + || options.matchCase !== this._options.matchCase + || options.wholeWord !== this._options.wholeWord; this._query = query; this._options = options; + if (changed) { + this._activeAnchor = undefined; + this._activeIndex = -1; + } this.recompute(); } @@ -215,13 +239,15 @@ export class ChatFindModel extends Disposable { return; } - const segments = buildSegments(this.getItems()); + const items = this.getItems(); + const segments = buildSegments(items); const matches: IChatFindMatch[] = []; - for (const segment of segments) { - if (matches.length >= MAX_FIND_MATCHES) { - break; + // Newest first: `buildSegments` produces transcript order, so both walks are reversed. + for (let index = segments.length - 1; index >= 0 && matches.length < MAX_FIND_MATCHES; index--) { + const segmentMatches = findMatchesInSegment(segments[index], regex, MAX_FIND_MATCHES - matches.length); + for (let occurrence = segmentMatches.length - 1; occurrence >= 0; occurrence--) { + matches.push(segmentMatches[occurrence]); } - matches.push(...findMatchesInSegment(segment, regex, MAX_FIND_MATCHES - matches.length)); } this._matches = matches; @@ -230,13 +256,14 @@ export class ChatFindModel extends Disposable { this._activeIndex = matches.findIndex(m => m.itemId === previousAnchor.itemId && m.partIndex === previousAnchor.partIndex && m.occurrenceIndex === previousAnchor.occurrenceIndex); } if (this._activeIndex < 0) { - this._activeIndex = matches.length > 0 ? 0 : -1; + this._activeIndex = this._seedActiveIndex(matches, items); } this._updateAnchor(); this._onDidChangeMatches.fire(); } + /** Moves to the next match in navigation order, which is the next one *back* in the transcript. */ next(): IChatFindMatch | undefined { if (this._matches.length === 0) { return undefined; @@ -247,6 +274,7 @@ export class ChatFindModel extends Disposable { return this.activeMatch; } + /** Moves to the previous match in navigation order, which is the next one *forward* in the transcript. */ previous(): IChatFindMatch | undefined { if (this._matches.length === 0) { return undefined; @@ -269,4 +297,32 @@ export class ChatFindModel extends Disposable { const active = this.activeMatch; this._activeAnchor = active ? { itemId: active.itemId, partIndex: active.partIndex, occurrenceIndex: active.occurrenceIndex } : undefined; } + + /** + * Picks where to start when no previous active match survived: the newest match that is not + * below the viewport, so Find begins at what the user is looking at rather than at the end of + * the transcript. Falls back to the newest match overall when everything on screen is older + * than every match. + */ + private _seedActiveIndex(matches: readonly IChatFindMatch[], items: readonly ChatTreeItem[]): number { + if (matches.length === 0) { + return -1; + } + const anchorItemId = this.getViewportAnchorItemId(); + if (anchorItemId === undefined) { + return 0; + } + const positions = new Map<string, number>(); + items.forEach((item, position) => positions.set(item.id, position)); + const anchorPosition = positions.get(anchorItemId); + if (anchorPosition === undefined) { + return 0; + } + // Newest first, so the first match at or above the anchor is the nearest one. + const seeded = matches.findIndex(match => { + const position = positions.get(match.itemId); + return position !== undefined && position <= anchorPosition; + }); + return seeded >= 0 ? seeded : 0; + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindWidget.ts index e6b751272d6347..303f2bd02eb17b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatFind/chatFindWidget.ts @@ -5,12 +5,13 @@ import './chatFindWidget.css'; import * as dom from '../../../../../../base/browser/dom.js'; -import { Delayer } from '../../../../../../base/common/async.js'; +import { DeferredPromise, Delayer } from '../../../../../../base/common/async.js'; import { createRegExp } from '../../../../../../base/common/strings.js'; import { isDefined } from '../../../../../../base/common/types.js'; import { Event } from '../../../../../../base/common/event.js'; import { MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { Range as EditorRange } from '../../../../../../editor/common/core/range.js'; +import { EditorOption } from '../../../../../../editor/common/config/editorOptions.js'; import { IEditorDecorationsCollection } from '../../../../../../editor/common/editorCommon.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -25,7 +26,7 @@ import { IChatListItemTemplate } from '../chatListRenderer.js'; import { CodeBlockPart } from '../chatContentParts/codeBlockPart.js'; import { ChatFindCommandId } from './chatFindCommandIds.js'; import { getChatFindHighlightRegistry, supportsCssHighlightApi } from './chatFindHighlights.js'; -import { ChatFindModel, IChatFindMatch } from './chatFindModel.js'; +import { ChatFindModel, IChatFindMatch, MAX_FIND_MATCHES } from './chatFindModel.js'; export interface IChatFindHost { readonly transcriptDomNode: HTMLElement; @@ -35,6 +36,13 @@ export interface IChatFindHost { getTemplateDataForRequestId(requestId: string | undefined): IChatListItemTemplate | undefined; readonly onDidRerenderRow: Event<IChatListItemTemplate>; editorsInUse(): Iterable<CodeBlockPart>; + /** Scroll offset of the transcript, in list content space. */ + getScrollTop(): number; + setScrollTop(scrollTop: number): void; + /** Height of the transcript's visible area. */ + getRenderHeight(): number; + /** Id of the last item intersecting the viewport, used to start Find from what is on screen. */ + getViewportAnchorItemId(): string | undefined; } /** Upper bound on the number of DOM ranges highlighted at once (only ever the currently mounted/visible rows). */ @@ -42,6 +50,18 @@ const MAX_VISIBLE_HIGHLIGHTS = 500; const CHAT_FIND_WIDGET_INITIAL_WIDTH = 350; +/** + * How long typing settles before Find searches. Searching per keystroke publishes a count for + * every prefix, so the label counts up and down while the user is still typing. + */ +const SEARCH_DEBOUNCE_DELAY = 150; + +/** + * Bounds how many times the result count waits for a fresh search to supersede the one it was + * waiting on, so continuous typing can't keep the label pending forever. + */ +const MAX_SETTLE_WAITS = 20; + const CURRENT_MATCH_HIGHLIGHT_NAME = 'chat-find-current-match'; const OTHER_MATCH_HIGHLIGHT_NAME = 'chat-find-other-match'; @@ -155,6 +175,27 @@ export function rangesEqual(a: Range, b: Range): boolean { && a.endContainer === b.endContainer && a.endOffset === b.endOffset; } +/** Breathing room kept between a revealed match and the edge of the transcript viewport. */ +const MATCH_REVEAL_PADDING = 30; + +/** + * The scroll offset that brings a match spanning `top`..`bottom` (measured from the top of the + * viewport) into view, or `undefined` when it is already comfortably visible. Moves by the least + * amount that clears the padding, and aligns the top of a match too tall to fit. + */ +export function computeRevealScrollTop(scrollTop: number, renderHeight: number, top: number, bottom: number): number | undefined { + const alignTop = () => Math.max(0, scrollTop + top - MATCH_REVEAL_PADDING); + if (top < MATCH_REVEAL_PADDING) { + return alignTop(); + } + if (bottom > renderHeight - MATCH_REVEAL_PADDING) { + return bottom - top > renderHeight - 2 * MATCH_REVEAL_PADDING + ? alignTop() + : Math.max(0, scrollTop + bottom - renderHeight + MATCH_REVEAL_PADDING); + } + return undefined; +} + interface ILocatedCodeMatch { readonly codeBlock: CodeBlockPart; readonly range: EditorRange; @@ -203,15 +244,26 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl private readonly _repaintScheduler = this._register(new MutableDisposable()); private readonly _revealScheduler = this._register(new MutableDisposable()); private readonly _recomputeDelayer = this._register(new Delayer<void>(200)); + private readonly _searchDelayer = this._register(new Delayer<void>(SEARCH_DEBOUNCE_DELAY)); private readonly _codeDecorations = new Map<CodeBlockPart, IEditorDecorationsCollection>(); private _lastFocusedElement: HTMLElement | undefined; private _lastNavigationWasPrevious = false; private _unlocatableSkips = 0; + private _pendingSearch: Promise<void> | undefined; + /** Pending while the active match is still being located, which can drop unreachable matches. */ + private _settleBarrier: DeferredPromise<void> | undefined; /** Bounds the skip walk so a query whose matches are all unlocatable cannot spin. */ private static readonly MAX_UNLOCATABLE_SKIPS = 50; + /** + * Frames to wait for a revealed row to mount before treating a match as unreachable. The list + * mounts and re-measures rows over several frames after a long scroll, so a single frame is + * not enough to tell "not there yet" from "not there". + */ + private static readonly MAX_LOCATE_ATTEMPTS = 4; + constructor( private readonly host: IChatFindHost, @IContextViewService contextViewService: IContextViewService, @@ -224,6 +276,7 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl super({ showCommonFindToggles: true, showResultCount: true, + matchesLimit: MAX_FIND_MATCHES, initialWidth: CHAT_FIND_WIDGET_INITIAL_WIDTH, enableSash: true, appendCaseSensitiveActionId: ChatFindCommandId.ToggleFindCaseSensitive, @@ -240,15 +293,14 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl this._findWidgetFocusedKey = ChatContextKeys.findWidgetFocused.bindTo(contextKeyService); this._findInputFocusedKey = ChatContextKeys.findInputFocused.bindTo(contextKeyService); - this._model = this._register(new ChatFindModel(() => this.host.getItems())); + this._model = this._register(new ChatFindModel(() => this.host.getItems(), () => this.host.getViewportAnchorItemId())); this._register(this._model.onDidChangeMatches(() => this._onMatchesChanged())); this._register(this.host.onDidChangeContent(() => { if (this.isVisible()) { this._recomputeDelayer.trigger(() => { this._model.recompute(); - // The row usually rerenders before this debounced pass, so its repaint ran - // against the previous match set; repaint again now the new matches exist. + // The row usually rerenders before this debounced pass, against the old matches. this._scheduleRepaint(); }).catch(() => { }); } @@ -271,6 +323,8 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl this._lastFocusedElement = this._targetWindow.document.activeElement as HTMLElement | undefined; } this._findWidgetVisibleKey.set(true); + // Opening reads the count before the seed text reaches the model, reporting "No results". + this._beginSettle(); if (focus) { super.reveal(seedText); } else { @@ -284,6 +338,11 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl super.hide(); this._findWidgetVisibleKey.reset(); this._recomputeDelayer.cancel(); + this._searchDelayer.cancel(); + this._pendingSearch = undefined; + this._completeSettle(); + this._revealScheduler.clear(); + this._repaintScheduler.clear(); this._clearHighlights(); this._model.clear(); this._restoreFocus(); @@ -292,9 +351,29 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl find(previous: boolean): void { this._lastNavigationWasPrevious = previous; this._unlocatableSkips = 0; + if (this._flushPendingSearch()) { + // The query was not searched yet, so Enter lands on its first match, not its second. + this._navigateToActive(); + void this.updateResultCount(); + return; + } this._advanceActiveMatch(previous); } + /** + * Runs a debounced search now, if one is still waiting. Returns whether it ran, so navigation + * acts on the query the user actually typed rather than the previous one. + */ + private _flushPendingSearch(): boolean { + if (!this._pendingSearch) { + return false; + } + this._searchDelayer.cancel(); + this._pendingSearch = undefined; + this._model.setQuery(this.inputValue, this._currentFindOptions()); + return true; + } + private _advanceActiveMatch(previous: boolean): void { if (previous) { this._model.previous(); @@ -306,12 +385,14 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl } /** - * Moves past a match the DOM cannot produce, so navigation never appears to do nothing. The - * index predicts where the renderer will put content, and a part nested in a lazily-built - * container has no DOM node to land on; rather than stall, continue in the same direction. + * Steps past a match the DOM cannot produce, so navigation never appears to do nothing. The + * index predicts where the renderer puts content, and a prediction can still be wrong for + * content whose placement is decided at render time; rather than stall, keep going the same + * way. Bounded so a query whose matches are all unlocatable cannot spin. */ private _skipUnlocatableMatch(): void { if (this._unlocatableSkips >= ChatFindWidget.MAX_UNLOCATABLE_SKIPS) { + this._completeSettle(); return; } this._unlocatableSkips++; @@ -319,6 +400,10 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl } findFirst(): void { + this._unlocatableSkips = 0; + // Toggling an option supersedes a keystroke still waiting out the debounce. + this._searchDelayer.cancel(); + this._pendingSearch = undefined; this._model.setQuery(this.inputValue, this._currentFindOptions()); this._navigateToActive(); } @@ -348,12 +433,76 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl } protected _onInputChanged(): boolean { - this._model.setQuery(this.inputValue, this._currentFindOptions()); - this._navigateToActive(); + this._unlocatableSkips = 0; + this._scheduleSearch(); + // Optimistic: keeps the buttons usable until `updateResultCount` corrects the label. return this._model.matches.length > 0; } + /** Whether the widget's query or options have moved on from what the model last searched. */ + private _isModelStale(): boolean { + const options = this._currentFindOptions(); + const current = this._model.options; + return this.inputValue !== this._model.query + || options.isRegex !== current.isRegex + || options.matchCase !== current.matchCase + || options.wholeWord !== current.wholeWord; + } + + /** + * Runs the search once typing pauses. Searching per keystroke would publish a count for every + * prefix, and each of those counts can then shed unreachable matches, so the label ends up + * ticking up and down before landing on the real number. + */ + private _scheduleSearch(): void { + // An option toggle also reaches here via `findFirst`; a duplicate would swallow Enter. + if (!this._isModelStale()) { + return; + } + const search = this._searchDelayer.trigger(() => { + this._model.setQuery(this.inputValue, this._currentFindOptions()); + this._navigateToActive(); + }); + this._pendingSearch = search; + search.catch(() => { }).finally(() => { + if (this._pendingSearch === search) { + this._pendingSearch = undefined; + } + }); + } + + /** Marks the start of locating an active match, if one is not already in progress. */ + private _beginSettle(): void { + this._settleBarrier ??= new DeferredPromise<void>(); + } + + /** Marks the active match as located, dropped, or given up on, releasing the result count. */ + private _completeSettle(): void { + const barrier = this._settleBarrier; + this._settleBarrier = undefined; + void barrier?.complete(); + } + + /** + * Waits for the query to be searched and its active match to be located. Locating can drop + * matches that turn out to be unreachable, so reading the count before then reports a total + * that is about to change. + */ + private async _whenSettled(): Promise<void> { + // `FindInput.onDidChange` reads the count before `onInput` schedules the search. + await Promise.resolve(); + for (let attempt = 0; attempt < MAX_SETTLE_WAITS; attempt++) { + const pending = [this._pendingSearch, this._settleBarrier?.p].filter(isDefined); + if (!pending.length) { + return; + } + // A newer search may have started while awaiting, so re-check rather than assume. + await Promise.all(pending).catch(() => { }); + } + } + protected async _getResultCount(): Promise<{ resultIndex: number; resultCount: number } | undefined> { + await this._whenSettled(); if (this._model.isInvalidRegex) { return undefined; } @@ -387,8 +536,10 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl private _navigateToActive(): void { const match = this._model.activeMatch; this._clearHighlights(); + this._beginSettle(); if (!match) { + this._completeSettle(); return; } @@ -400,9 +551,18 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl this._revealScheduler.value = dom.scheduleAtNextAnimationFrame(this._targetWindow, () => this._revealActiveMatch(match)); } - private _revealActiveMatch(match: IChatFindMatch): void { + private _revealActiveMatch(match: IChatFindMatch, attempt: number = 0): void { const locatedMatch = this._locateMatch(match); if (!locatedMatch) { + // A long jump outruns the list, which mounts and re-measures rows over later frames. + if (attempt < ChatFindWidget.MAX_LOCATE_ATTEMPTS) { + const item = this._findItemForMatch(match); + if (item) { + this.host.reveal(item); + } + this._revealScheduler.value = dom.scheduleAtNextAnimationFrame(this._targetWindow, () => this._revealActiveMatch(match, attempt + 1)); + return; + } this._repaintVisibleHighlights(); this._skipUnlocatableMatch(); return; @@ -411,6 +571,8 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl const revealCodeMatch = () => { locatedMatch.codeBlock.editor.revealRangeInCenter(locatedMatch.range); this._repaintVisibleHighlights(); + this._revealRect(this._codeMatchRect(locatedMatch)); + this._completeSettle(); }; if (openAncestorDisclosures(this.host.transcriptDomNode, locatedMatch.codeBlock.element)) { this._revealScheduler.value = dom.scheduleAtNextAnimationFrame(this._targetWindow, revealCodeMatch); @@ -424,15 +586,76 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl const opened = this._openAncestorDisclosures(range); this._repaintVisibleHighlights(); if (opened) { - this._revealScheduler.value = dom.scheduleAtNextAnimationFrame(this._targetWindow, () => this._scrollRangeIntoView(range)); + this._revealScheduler.value = dom.scheduleAtNextAnimationFrame(this._targetWindow, () => { + this._revealRect(this._rangeRect(range)); + this._completeSettle(); + }); } else { - this._scrollRangeIntoView(range); + this._revealRect(this._rangeRect(range)); + this._completeSettle(); } } - private _scrollRangeIntoView(range: Range | undefined): void { - const container = range && (range.startContainer.nodeType === this._targetWindow.Node.ELEMENT_NODE ? range.startContainer as Element : range.startContainer.parentElement); - container?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + /** + * Scrolls the transcript so `rect` is in view, in either direction, and reports whether it + * moved. + * + * Deliberately not `Element.scrollIntoView`: the chat list picks up native scrolling by + * reading the container's `scrollTop` and resetting it to `0` (see `scrollToActiveElement` in + * `listView.ts`), so the browser can only ever hand it a downward delta. Scrolling up — which + * is now the common direction, since matches run newest first — would silently do nothing. + */ + private _scrollRectIntoView(rect: { readonly top: number; readonly bottom: number } | undefined): boolean { + if (!rect) { + return false; + } + const viewportTop = this.host.transcriptDomNode.getBoundingClientRect().top; + const scrollTop = computeRevealScrollTop( + this.host.getScrollTop(), + this.host.getRenderHeight(), + rect.top - viewportTop, + rect.bottom - viewportTop + ); + if (scrollTop === undefined) { + return false; + } + this.host.setScrollTop(scrollTop); + return true; + } + + /** Scrolls to a match and repaints once the rows the scroll brought into view have mounted. */ + private _revealRect(rect: { readonly top: number; readonly bottom: number } | undefined): void { + if (this._scrollRectIntoView(rect)) { + this._scheduleRepaint(); + } + } + + /** The match's own rectangle, falling back to its element for ranges that measure as empty. */ + private _rangeRect(range: Range): { readonly top: number; readonly bottom: number } | undefined { + const rect = range.getBoundingClientRect(); + if (rect.height > 0) { + return rect; + } + const element = range.startContainer.nodeType === this._targetWindow.Node.ELEMENT_NODE + ? range.startContainer as Element + : range.startContainer.parentElement; + return element?.getBoundingClientRect(); + } + + /** + * The matched line's rectangle inside an embedded editor. Only visible lines exist in the DOM, + * so the position is derived from the editor's layout rather than looked up as a node. + */ + private _codeMatchRect(codeMatch: ILocatedCodeMatch): { readonly top: number; readonly bottom: number } | undefined { + const editor = codeMatch.codeBlock.editor; + const editorDomNode = editor.getDomNode(); + if (!editorDomNode) { + return undefined; + } + const lineTop = editorDomNode.getBoundingClientRect().top + + editor.getTopForLineNumber(codeMatch.range.startLineNumber) + - editor.getScrollTop(); + return { top: lineTop, bottom: lineTop + editor.getOption(EditorOption.lineHeight) }; } private _findItemForMatch(match: IChatFindMatch): ChatTreeItem | undefined { @@ -620,6 +843,7 @@ export class ChatFindWidget extends SimpleFindWidget implements IChatFindControl } override dispose(): void { + this._completeSettle(); this._clearHighlights(); super.dispose(); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index e4968fea18c662..584dcdfa662861 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -402,6 +402,27 @@ export class ChatListWidget extends Disposable { return this._lastItem; } + /** + * The bottom-most item intersecting the viewport, or `undefined` when the list is empty. + * Reads the layout height model, so it resolves independently of which rows are mounted. + */ + get lastVisibleItem(): ChatTreeItem | undefined { + const items = this._viewModel?.getItems(); + if (!items?.length) { + return undefined; + } + const viewportBottom = this._tree.scrollTop + this._tree.renderHeight; + // Walking back from the end settles in a step or two for the common case of a + // transcript sitting at the bottom. + for (let index = items.length - 1; index >= 0; index--) { + const top = this.getElementTop(items[index]); + if (top !== undefined && top <= viewportBottom) { + return items[index]; + } + } + return items[0]; + } + //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 124e22682a6519..616f1effecf586 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1065,6 +1065,10 @@ export class ChatWidget extends Disposable implements IChatWidget { getTemplateDataForRequestId: (requestId) => this.getTemplateDataForRequestId(requestId), onDidRerenderRow: this.onDidRerenderRow, editorsInUse: () => this.listWidget.editorsInUse(), + getScrollTop: () => this.listWidget.scrollTop, + setScrollTop: (scrollTop) => { this.listWidget.scrollTop = scrollTop; }, + getRenderHeight: () => this.listWidget.renderHeight, + getViewportAnchorItemId: () => this.listWidget.lastVisibleItem?.id, }; this._findController = this._register(this.instantiationService.createInstance(ChatFindWidget, host)); // Focusing the Find widget must count as focusing this widget, so diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatFindModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatFindModel.test.ts index e5ea0d7bfc6f89..4f235b55f32cf0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatFindModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatFindModel.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ChatFindModel } from '../../../browser/widget/chatFind/chatFindModel.js'; import { ChatTreeItem } from '../../../browser/chat.js'; @@ -15,7 +16,7 @@ function fakeRequest(id: string, messageText: string): ChatTreeItem { } /** Builds a minimal fake response item satisfying `isResponseVM` (`typeof item.setVote !== 'undefined'`). */ -function fakeResponse(id: string, value: unknown[], errorDetails?: { message: string }, codeCitations?: unknown[]): ChatTreeItem { +function fakeResponse(id: string, value: unknown[], errorDetails?: { message: string; responseIsFiltered?: boolean }, codeCitations?: unknown[]): ChatTreeItem { const response = { value }; return { id, @@ -33,6 +34,10 @@ function markdown(text: string) { return { kind: 'markdownContent', content: new MarkdownString(text) }; } +function inlineReference(path: string) { + return { kind: 'inlineReference', inlineReference: URI.file(path) }; +} + function thinking(text: string) { return { kind: 'thinking', value: text }; } @@ -52,11 +57,13 @@ suite('ChatFindModel', () => { const model = new ChatFindModel(() => items); model.setQuery('array', { isRegex: false, matchCase: false, wholeWord: false }); - // One "array" in the request, two in the response markdown text. - assert.strictEqual(model.matches.length, 3); - assert.strictEqual(model.matches[0].itemId, 'req1'); - assert.strictEqual(model.matches[1].itemId, 'resp1'); - assert.strictEqual(model.matches[2].itemId, 'resp1'); + // One "array" in the request, two in the response markdown text. Navigation runs newest + // first, so the response's matches come before the request's, in reverse order. + assert.deepStrictEqual(model.matches.map(match => ({ itemId: match.itemId, occurrenceIndex: match.occurrenceIndex })), [ + { itemId: 'resp1', occurrenceIndex: 1 }, + { itemId: 'resp1', occurrenceIndex: 0 }, + { itemId: 'req1', occurrenceIndex: 0 }, + ]); model.dispose(); }); @@ -111,8 +118,9 @@ suite('ChatFindModel', () => { } }); - test('caps the total match count across segments', () => { - // Two segments that each exceed the cap on their own: the total must still be bounded. + test('caps the total match count, keeping the newest', () => { + // Two segments that each exceed the cap on their own: the total must still be bounded, + // and truncation must drop the oldest rather than the most recent. const many = new Array(9000).fill('needle').join(' '); const items = [ fakeResponse('resp1', [markdown(many)]), @@ -122,6 +130,80 @@ suite('ChatFindModel', () => { model.setQuery('needle', { isRegex: false, matchCase: false, wholeWord: false }); assert.strictEqual(model.matches.length, 9999); + assert.strictEqual(model.matches[0].itemId, 'resp2', 'navigation starts at the newest match'); + assert.strictEqual(model.matches[0].occurrenceIndex, 8999, 'and at that response\'s newest occurrence'); + model.dispose(); + }); + + test('a single over-limit segment keeps its newest occurrences', () => { + // Truncating a segment from the front would retain occurrences 0..9998 and drop the very + // matches navigation reaches first. + const items = [fakeResponse('resp1', [markdown(new Array(10_500).fill('needle').join(' '))])]; + const model = new ChatFindModel(() => items); + model.setQuery('needle', { isRegex: false, matchCase: false, wholeWord: false }); + + assert.deepStrictEqual({ + total: model.matches.length, + newest: model.matches[0].occurrenceIndex, + oldest: model.matches[model.matches.length - 1].occurrenceIndex, + }, { + total: 9999, + newest: 10_499, + oldest: 501, + }); + model.dispose(); + }); + + test('starts at the match nearest the viewport rather than the end of the transcript', () => { + // Most of a chat is in the past, so Find starts from what the user is looking at. + const items = [ + fakeResponse('resp1', [markdown('needle one')]), + fakeResponse('resp2', [markdown('needle two')]), + fakeResponse('resp3', [markdown('needle three')]), + ]; + const model = new ChatFindModel(() => items, () => 'resp2'); + model.setQuery('needle', { isRegex: false, matchCase: false, wholeWord: false }); + + assert.deepStrictEqual({ + order: model.matches.map(match => match.itemId), + activeIndex: model.activeIndex, + activeItemId: model.activeMatch?.itemId, + }, { + order: ['resp3', 'resp2', 'resp1'], + activeIndex: 1, + activeItemId: 'resp2', + }); + model.dispose(); + }); + + test('falls back to the newest match when every match is below the viewport', () => { + const items = [ + fakeResponse('resp1', [markdown('no match here')]), + fakeResponse('resp2', [markdown('needle two')]), + fakeResponse('resp3', [markdown('needle three')]), + ]; + const model = new ChatFindModel(() => items, () => 'resp1'); + model.setQuery('needle', { isRegex: false, matchCase: false, wholeWord: false }); + + assert.strictEqual(model.activeMatch?.itemId, 'resp3'); + model.dispose(); + }); + + test('re-seeds from the viewport when a new query has no surviving anchor', () => { + let anchorItemId = 'resp3'; + const items = [ + fakeResponse('resp1', [markdown('alpha beta')]), + fakeResponse('resp2', [markdown('alpha beta')]), + fakeResponse('resp3', [markdown('alpha beta')]), + ]; + const model = new ChatFindModel(() => items, () => anchorItemId); + + model.setQuery('alpha', { isRegex: false, matchCase: false, wholeWord: false }); + assert.strictEqual(model.activeMatch?.itemId, 'resp3'); + + anchorItemId = 'resp1'; + model.setQuery('beta', { isRegex: false, matchCase: false, wholeWord: false }); + assert.strictEqual(model.activeMatch?.itemId, 'resp1', 'the new query starts from the moved viewport'); model.dispose(); }); @@ -134,8 +216,102 @@ suite('ChatFindModel', () => { partIndex: match.partIndex, occurrenceIndex: match.occurrenceIndex, })), [ - { partIndex: 1, occurrenceIndex: 0 }, { partIndex: 1, occurrenceIndex: 1 }, + { partIndex: 1, occurrenceIndex: 0 }, + ]); + model.dispose(); + }); + + test('does not index markdown link targets, which never render as text', () => { + // Reproduces a real transcript: a response listing edited files as markdown links whose + // targets repeat the branch name. Only the link label is rendered, so counting the target + // inflates the total with matches navigation can never reach. + const items = [ + fakeRequest('req1', 'Change port to 1242'), + fakeResponse('resp1', [markdown([ + 'Changes include:', + '', + '- Added [src/](/Users/me/simple-server.worktrees/change-port-to-1242/src)', + '- Added [index.html](/Users/me/simple-server.worktrees/change-port-to-1242/index.html)', + '- Added [dist/](/Users/me/simple-server.worktrees/change-port-to-1242/.gitignore)', + ].join('\n'))]), + ]; + const model = new ChatFindModel(() => items); + model.setQuery('chang', { isRegex: false, matchCase: false, wholeWord: false }); + + // "Change port to 1242" and "Changes include:" — not the three link targets. + assert.deepStrictEqual(model.matches.map(match => match.itemId), ['resp1', 'req1']); + model.dispose(); + }); + + test('still indexes the visible label of a link inside a list item', () => { + const items = [fakeResponse('resp1', [markdown('- Added [needle.ts](/some/path/needle.ts)')])]; + const model = new ChatFindModel(() => items); + model.setQuery('needle', { isRegex: false, matchCase: false, wholeWord: false }); + + assert.strictEqual(model.matches.length, 1, 'the label renders, so it stays findable'); + model.dispose(); + }); + + test('does not fuse parts the renderer merges into one block', () => { + // An inline reference splits the surrounding markdown into three parts that render as one + // line. Concatenating their trimmed text would index `Seefoo.tsfor details`, hiding the + // sentence that is actually on screen. + const items = [fakeResponse('resp1', [ + markdown('See '), + inlineReference('/repo/foo.ts'), + markdown(' for details'), + ])]; + const model = new ChatFindModel(() => items); + + model.setQuery('See foo.ts for details', { isRegex: false, matchCase: false, wholeWord: false }); + assert.strictEqual(model.matches.length, 1, 'the rendered sentence is findable'); + + model.setQuery('foo.tsfor', { isRegex: false, matchCase: false, wholeWord: false }); + assert.strictEqual(model.matches.length, 0, 'the fused text never existed on screen'); + model.dispose(); + }); + + test('keeps a paragraph break between parts that render as separate blocks', () => { + const items = [fakeResponse('resp1', [markdown('first para\n\n'), markdown('second para')])]; + const model = new ChatFindModel(() => items); + + model.setQuery('para second', { isRegex: false, matchCase: false, wholeWord: false }); + assert.strictEqual(model.matches.length, 0, 'a block boundary is not a space'); + model.dispose(); + }); + + test('does not index a filtered response, whose content the renderer drops', () => { + // A filtered response renders only its error message; the references slot, the body and + // the citations are all dropped, so indexing the body counts unreachable matches. + const items = [fakeResponse( + 'resp1', + [markdown('needle in the body')], + { message: 'Sorry, the response was filtered', responseIsFiltered: true }, + )]; + const model = new ChatFindModel(() => items); + + model.setQuery('needle', { isRegex: false, matchCase: false, wholeWord: false }); + assert.strictEqual(model.matches.length, 0); + model.dispose(); + }); + + test('still indexes the error message of a filtered response, which does render', () => { + const items = [fakeResponse( + 'resp1', + [markdown('needle in the body')], + { message: 'Sorry, the needle was filtered', responseIsFiltered: true }, + )]; + const model = new ChatFindModel(() => items); + + model.setQuery('needle', { isRegex: false, matchCase: false, wholeWord: false }); + + assert.deepStrictEqual(model.matches.map(match => ({ + partIndex: match.partIndex, + scopeStartPartIndex: match.scopeStartPartIndex, + })), [ + // Row-level text starts at 0: nothing of the response body precedes it. + { partIndex: -1, scopeStartPartIndex: 0 }, ]); model.dispose(); }); @@ -237,8 +413,8 @@ suite('ChatFindModel', () => { scopeStartPartIndex: match.scopeStartPartIndex, occurrenceIndex: match.occurrenceIndex, })), [ - { partIndex: 1, scopeStartPartIndex: undefined, occurrenceIndex: 0 }, { partIndex: -1, scopeStartPartIndex: 3, occurrenceIndex: 0 }, + { partIndex: 1, scopeStartPartIndex: undefined, occurrenceIndex: 0 }, ]); model.dispose(); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatFindWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatFindWidget.test.ts index 8b76c5f2d05344..32b91762818da6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatFindWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatFindWidget.test.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; import { createRegExp } from '../../../../../../base/common/strings.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { ChatFindWidget, findMatchRangesInDom, openAncestorDisclosures, rangesEqual, shouldCaptureFocusBeforeShow } from '../../../browser/widget/chatFind/chatFindWidget.js'; +import { ChatFindWidget, computeRevealScrollTop, findMatchRangesInDom, openAncestorDisclosures, rangesEqual, shouldCaptureFocusBeforeShow } from '../../../browser/widget/chatFind/chatFindWidget.js'; suite('ChatFindWidget DOM highlighting', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -138,6 +139,89 @@ suite('ChatFindWidget DOM highlighting', () => { }); }); +/** + * Covers revealing a match in both directions. The chat list only picks up native scrolling as a + * downward delta (see `scrollToActiveElement` in `listView.ts`), so scrolling up — including the + * wrap from the last match back to the first — has to be driven through the list's own scroll + * offset. These tests pin that arithmetic. + */ +suite('ChatFindWidget match reveal', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + // scrollTop 500, viewport 400 tall, padding 30. + const scrollTop = 500; + const renderHeight = 400; + + test('leaves a match that is already comfortably in view alone', () => { + assert.strictEqual(computeRevealScrollTop(scrollTop, renderHeight, 100, 120), undefined); + }); + + test('scrolls up to a match above the viewport', () => { + // The wrap-around case: the match sits 60px above the top edge. + assert.strictEqual(computeRevealScrollTop(scrollTop, renderHeight, -60, -40), 500 - 60 - 30); + }); + + test('scrolls down by the least amount that clears the bottom edge', () => { + assert.strictEqual(computeRevealScrollTop(scrollTop, renderHeight, 390, 410), 500 + 410 - 400 + 30); + }); + + test('aligns the top of a match too tall to fit the viewport', () => { + assert.strictEqual(computeRevealScrollTop(scrollTop, renderHeight, 40, 600), 500 + 40 - 30); + }); + + test('never scrolls above the start of the transcript', () => { + assert.strictEqual(computeRevealScrollTop(10, renderHeight, -200, -180), 0); + }); +}); + +/** + * Drives the reveal through the widget's private scroll helper with a fake host, so the + * measure-and-scroll wiring is covered without the widget's service graph. + */ +suite('ChatFindWidget scroll wiring', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const scrollRectIntoView = Reflect.get(ChatFindWidget.prototype, '_scrollRectIntoView') as (this: IScrollHarness, rect: { top: number; bottom: number } | undefined) => boolean; + + interface IScrollHarness { + host: { + transcriptDomNode: { getBoundingClientRect(): { top: number } }; + getScrollTop(): number; + setScrollTop(scrollTop: number): void; + getRenderHeight(): number; + }; + } + + /** Scrolls to a match whose rect is given in client coordinates, and reports the writes. */ + function reveal(rect: { top: number; bottom: number } | undefined, scrollTop = 500) { + const writes: number[] = []; + const harness: IScrollHarness = { + host: { + // The transcript viewport starts 100px down the client area. + transcriptDomNode: { getBoundingClientRect: () => ({ top: 100 }) }, + getScrollTop: () => scrollTop, + setScrollTop: (value: number) => writes.push(value), + getRenderHeight: () => 400, + }, + }; + const scrolled = scrollRectIntoView.call(harness, rect); + return { writes, scrolled }; + } + + test('scrolls the list up when the match is above the viewport', () => { + // Client top 40 is 60px above the transcript's top edge. + assert.deepStrictEqual(reveal({ top: 40, bottom: 60 }), { writes: [500 - 60 - 30], scrolled: true }); + }); + + test('does not touch the list when the match is already in view', () => { + assert.deepStrictEqual(reveal({ top: 200, bottom: 220 }), { writes: [], scrolled: false }); + }); + + test('does nothing when the match could not be measured', () => { + assert.deepStrictEqual(reveal(undefined), { writes: [], scrolled: false }); + }); +}); + /** * Exercises the walk that moves past matches the DOM cannot produce. Driving the private members * directly keeps the test free of the widget's service graph while still covering the real @@ -152,16 +236,18 @@ suite('ChatFindWidget unlocatable match walk', () => { interface IWalkHarness { _unlocatableSkips: number; _lastNavigationWasPrevious: boolean; + _completeSettle(): void; _advanceActiveMatch(previous: boolean): void; } - /** Walks `locatable` from `startIndex`, skipping entries the DOM cannot produce. */ + /** Walks `locatable` from `startIndex`, stepping past entries the DOM cannot produce. */ function runWalk(locatable: readonly boolean[], startIndex: number, previous: boolean) { const directions: boolean[] = []; let index = startIndex; const harness: IWalkHarness = { _unlocatableSkips: 0, _lastNavigationWasPrevious: previous, + _completeSettle() { }, _advanceActiveMatch(wasPrevious: boolean) { directions.push(wasPrevious); index = (index + (wasPrevious ? -1 : 1) + locatable.length) % locatable.length; @@ -196,3 +282,89 @@ suite('ChatFindWidget unlocatable match walk', () => { assert.strictEqual(result.skips, maxSkips, 'gave up at the cap instead of spinning'); }); }); + +/** + * Covers holding the result count until the search and its match location have settled, so the + * label shows the final number instead of counting up and down while the user types. + */ +suite('ChatFindWidget settled result count', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const whenSettled = Reflect.get(ChatFindWidget.prototype, '_whenSettled') as (this: ISettleHarness) => Promise<void>; + const beginSettle = Reflect.get(ChatFindWidget.prototype, '_beginSettle') as (this: ISettleHarness) => void; + const completeSettle = Reflect.get(ChatFindWidget.prototype, '_completeSettle') as (this: ISettleHarness) => void; + + interface ISettleHarness { + _pendingSearch: Promise<void> | undefined; + _settleBarrier: DeferredPromise<void> | undefined; + } + + test('waits for an in-flight match location before reporting', async () => { + const harness: ISettleHarness = { _pendingSearch: undefined, _settleBarrier: undefined }; + beginSettle.call(harness); + + let settled = false; + const waiting = whenSettled.call(harness).then(() => { settled = true; }); + + await Promise.resolve(); + assert.strictEqual(settled, false, 'still locating, so the count must not be read yet'); + + completeSettle.call(harness); + await waiting; + assert.strictEqual(settled, true); + }); + + test('waits for a debounced search that has not run yet', async () => { + const search = new DeferredPromise<void>(); + const harness: ISettleHarness = { _pendingSearch: search.p, _settleBarrier: undefined }; + + let settled = false; + const waiting = whenSettled.call(harness).then(() => { settled = true; }); + + await Promise.resolve(); + assert.strictEqual(settled, false, 'the query has not been searched yet'); + + harness._pendingSearch = undefined; + await search.complete(); + await waiting; + assert.strictEqual(settled, true); + }); + + test('keeps waiting when a newer keystroke supersedes the search it was waiting on', async () => { + const first = new DeferredPromise<void>(); + const second = new DeferredPromise<void>(); + const harness: ISettleHarness = { _pendingSearch: first.p, _settleBarrier: undefined }; + + let settled = false; + const waiting = whenSettled.call(harness).then(() => { settled = true; }); + + // Typing again while the first search was pending. + harness._pendingSearch = second.p; + await first.complete(); + await Promise.resolve(); + assert.strictEqual(settled, false, 'the newer search has to finish too'); + + harness._pendingSearch = undefined; + await second.complete(); + await waiting; + assert.strictEqual(settled, true); + }); + + test('returns immediately when nothing is in flight', async () => { + const harness: ISettleHarness = { _pendingSearch: undefined, _settleBarrier: undefined }; + + await whenSettled.call(harness); + }); + + test('completing twice is safe, so cleanup paths cannot strand a waiter', async () => { + const harness: ISettleHarness = { _pendingSearch: undefined, _settleBarrier: undefined }; + beginSettle.call(harness); + const waiting = whenSettled.call(harness); + + completeSettle.call(harness); + completeSettle.call(harness); + + await waiting; + assert.strictEqual(harness._settleBarrier, undefined); + }); +}); From 07c20d96cf3f2cbc8142ac7079ba9048cf7f6134 Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:10:20 -1000 Subject: [PATCH 15/15] Dispose Agents Window terminals when sessions are archived (#331886) * Dispose archived Agents terminals Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix worktree terminal test narrowing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Guard late archive terminal cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionsTerminalContribution.ts | 179 ++++-- .../sessionsTerminalContribution.test.ts | 521 +++++++++++++++++- 2 files changed, 634 insertions(+), 66 deletions(-) diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 549624c79bb243..26213a04989afc 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -5,6 +5,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, IReader } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -61,14 +62,19 @@ function getSessionTerminalInfo(session: ISession | undefined, reader?: IReader) return { cwd }; } +function getSessionWorktreeCwd(session: ISession): URI | undefined { + const worktree = session.workspace.get()?.folders[0]?.gitRepository?.workTreeUri; + return worktree?.scheme === AGENT_HOST_SCHEME ? undefined : worktree; +} + /** * Manages terminal instances in the sessions window, ensuring: * - A terminal exists for the active session's worktree (or repository if no worktree). * - Terminals are tracked per session id and shown/hidden based on that association. * - Terminals created before session-id tracking fall back to initial cwd matching * until they are associated with a session in this window. - * - Terminals for archived/removed sessions are hidden/closed using their tracked - * session id association while keeping the active terminal protected. + * - Terminals for archived/removed sessions are closed using their tracked + * session id association. */ export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution { @@ -80,6 +86,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben private readonly _standaloneTerminalIds = new Set<number>(); /** In-flight terminal work for drafts, retained only until each operation settles. */ private readonly _pendingTerminalOperations = new Map<string, IPendingTerminalOperation>(); + private readonly _sessionTerminalGenerations = new Map<string, number>(); /** * Session ids already processed as archived. The archive cleanup runs only @@ -140,7 +147,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben // This is a little hacky but I don't see any better approach. this._register(autorun(reader => { const session = this._sessionsService.activeSession.read(reader); - if (session?.loading.read(reader)) { + if (session?.loading.read(reader) || session?.isArchived.read(reader) || session?.worktreePending?.read(reader)) { this._agentHostTerminalService.setDefaultCwd(undefined); return; } @@ -151,7 +158,15 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben // React to active session changes — use worktree/repo for background sessions, home dir otherwise this._register(autorun(reader => { const session = this._sessionsService.activeSession.read(reader); - if (session?.loading.read(reader)) { + const isArchived = session?.isArchived.read(reader); + const worktreePending = session?.worktreePending?.read(reader); + if (session && !isArchived && this._archivedSessionIds.delete(session.sessionId)) { + this._invalidateTerminalOperations(session.sessionId); + } + if (session?.loading.read(reader) || isArchived || worktreePending) { + if (session && (isArchived || worktreePending)) { + this._invalidateTerminalOperations(session.sessionId); + } this._activeKey = undefined; this._activeSessionId = undefined; return; @@ -204,12 +219,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben // Clean up terminals for archived/removed sessions using their tracked // session-to-terminal associations. // - // Archive vs remove differ in how aggressive the cleanup is: - // - Archiving is reversible and terminals can be reused by - // the same session, so we only HIDE the terminal (the pty survives and can - // be shown again on unarchive or reuse). See `_hideTerminalsForSession`. - // - Removal is an explicit, destructive user action, so we KILL the - // terminal. See `_closeTerminalsForSession`. + // Archive disposes session-owned terminals; restore creates a fresh terminal after worktree readiness. // // The archive cleanup runs only on the not-archived → archived transition. // The provider keeps archived sessions cached and re-emits them in @@ -217,11 +227,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben // re-run the cwd cleanup each time and sweep terminals the user opened // after archiving. // - // Both paths are asynchronous and can land while the user is working in a - // just-opened terminal at this cwd (e.g. removal also covers untitled → - // committed graduation via `onDidReplaceSession`, which surfaces the - // skeleton in `removed`). The focused (active) terminal is therefore never - // touched on either path. See #313510, #318645. + // Removal protects the active terminal because `removed` also represents untitled → committed graduation. this._register(this._sessionsManagementService.onDidChangeSessions(e => { // Only act on the not-archived → archived transition; ignore re-emits @@ -239,10 +245,13 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben if (session.isArchived.get()) { if (!this._archivedSessionIds.has(session.sessionId)) { this._archivedSessionIds.add(session.sessionId); + this._invalidateTerminalOperations(session.sessionId); justArchived.push(session); } } else { - this._archivedSessionIds.delete(session.sessionId); + if (this._archivedSessionIds.delete(session.sessionId)) { + this._invalidateTerminalOperations(session.sessionId); + } } } for (const session of e.removed) { @@ -256,7 +265,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben void this._closeTerminalsForSession(session.sessionId, `session removed (${session.sessionId})`).finally(() => this._sessionTerminals.delete(session.sessionId)); } for (const session of justArchived) { - void this._hideTerminalsForSession(session.sessionId, `session archived (${session.sessionId})`); + void this._closeArchivedSessionTerminals(session); } })); } @@ -276,16 +285,17 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben return this._ensureTerminal(cwd, focus, session); } + const generation = this._getTerminalOperationGeneration(session.sessionId); this._beginTerminalOperation(session.sessionId); try { - return await this._ensureTerminal(cwd, focus, session); + return await this._ensureTerminal(cwd, focus, session, generation); } finally { this._endTerminalOperation(session.sessionId); } } - private async _ensureTerminal(cwd: URI, focus: boolean, session?: ISession): Promise<ITerminalInstance[]> { - if (session && this._pendingTerminalOperations.get(session.sessionId)?.replaced) { + private async _ensureTerminal(cwd: URI, focus: boolean, session?: ISession, generation?: number): Promise<ITerminalInstance[]> { + if (session && this._isTerminalOperationCancelled(session, generation)) { return []; } @@ -293,7 +303,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben let existing = session ? this._getTrackedTerminalsForSession(session.sessionId) : []; if (existing.length === 0) { existing = await this._findTerminalsForKey(key, { excludeTracked: !!session }); - if (session && this._pendingTerminalOperations.get(session.sessionId)?.replaced) { + if (session && this._isTerminalOperationCancelled(session, generation)) { return []; } } @@ -305,8 +315,11 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben if (!createdInstance) { return []; } - if (session && this._pendingTerminalOperations.get(session.sessionId)?.replaced) { + if (session && this._isTerminalOperationCancelled(session, generation)) { await this._terminalService.safeDisposeTerminal(createdInstance); + if (!createdInstance.isDisposed) { + this._trackTerminalsForSession(session.sessionId, [createdInstance]); + } return []; } existing = [createdInstance]; @@ -329,6 +342,22 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben return existing; } + private _isTerminalOperationCancelled(session: ISession, generation = this._getTerminalOperationGeneration(session.sessionId)): boolean { + return this._pendingTerminalOperations.get(session.sessionId)?.replaced === true + || this._getTerminalOperationGeneration(session.sessionId) !== generation + || this._archivedSessionIds.has(session.sessionId) + || session.isArchived.get() + || session.worktreePending?.get() === true; + } + + private _getTerminalOperationGeneration(sessionId: string): number { + return this._sessionTerminalGenerations.get(sessionId) ?? 0; + } + + private _invalidateTerminalOperations(sessionId: string): void { + this._sessionTerminalGenerations.set(sessionId, this._getTerminalOperationGeneration(sessionId) + 1); + } + /** * Creates a terminal for the given cwd. If the session is backed by an * agent host, creates an agent host terminal; otherwise creates a local one. @@ -366,6 +395,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben this._beginTerminalOperation(session.sessionId); try { + const generation = this._getTerminalOperationGeneration(session.sessionId); const info = getSessionTerminalInfo(session); const targetPath = info?.cwd ?? await this._pathService.userHome(); const targetKey = targetPath.fsPath.toLowerCase(); @@ -375,7 +405,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben this._activeKey = targetKey; this._activeSessionId = session.sessionId; - const instances = await this._ensureTerminal(targetPath, false, session); + const instances = await this._ensureTerminal(targetPath, false, session, generation); // If the active session or key changed while we were awaiting, a newer // call has taken over — skip the visibility update to avoid flicker. @@ -658,35 +688,96 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben } } - /** - * Hides (moves to background) terminals associated with the given session id - * without disposing them. Used when a session is archived ("Mark as Done"): - * archiving is reversible and the pty must survive so it can be shown again. - * - * Archiving is asynchronous and can land while the user is working in a - * just-opened terminal at this cwd, so the focused (active) instance is - * never hidden out from under the user. - * - * {@link reason} is logged for each hidden terminal so unexpected visibility - * changes in the agents window can be diagnosed from the logs. See #313510, - * #318645. - */ - private async _hideTerminalsForSession(sessionId: string, reason: string): Promise<void> { - const protectedInstanceId = this._terminalService.activeInstance?.instanceId; - for (const instance of this._getTrackedTerminalsForSession(sessionId)) { - if (protectedInstanceId !== undefined && instance.instanceId === protectedInstanceId) { - this._logService.info(`[SessionsTerminal] Skipping active terminal ${instance.instanceId} for session ${sessionId} (user is working in it)`); + private async _closeArchivedSessionTerminals(session: ISession): Promise<void> { + const cleanupGeneration = this._getTerminalOperationGeneration(session.sessionId); + const terminals = new Map(this._getTrackedTerminalsForSession(session.sessionId).map(instance => [instance.instanceId, instance])); + const untrackedWorktreeTerminalIds = new Set<number>(); + const worktreeCwd = getSessionWorktreeCwd(session); + const anotherLiveSessionSharesWorktree = worktreeCwd && this._sessionsManagementService.getSessions().some(candidate => + candidate.sessionId !== session.sessionId + && !candidate.isArchived.get() + && isEqual(getSessionWorktreeCwd(candidate), worktreeCwd) + ); + if (worktreeCwd && !anotherLiveSessionSharesWorktree) { + for (const instance of await this._findUntrackedTerminalsForResource(worktreeCwd)) { + if (instance.instanceId === this._terminalService.activeInstance?.instanceId) { + continue; + } + terminals.set(instance.instanceId, instance); + untrackedWorktreeTerminalIds.add(instance.instanceId); + } + } + if (!this._isArchiveCleanupCurrent(session.sessionId, cleanupGeneration)) { + return; + } + + for (const instance of terminals.values()) { + if (!this._isArchiveCleanupCurrent(session.sessionId, cleanupGeneration)) { + return; + } + if (untrackedWorktreeTerminalIds.has(instance.instanceId) + && (this._isTerminalTracked(instance.instanceId) + || this._standaloneTerminalIds.has(instance.instanceId) + || this._terminalService.activeInstance?.instanceId === instance.instanceId)) { continue; } - const availableInstance = this._getAvailableTerminal(instance, `hide archived terminal for session ${sessionId}`); + const availableInstance = this._getAvailableTerminal(instance, `close archived session terminal for session ${session.sessionId}`); if (!availableInstance) { continue; } - this._logService.info(`[SessionsTerminal] Hiding terminal ${availableInstance.instanceId} (session: ${sessionId}, reason: ${reason})`); - this._terminalService.moveToBackground(availableInstance); + this._logService.info(`[SessionsTerminal] Killing terminal ${availableInstance.instanceId} (session archived: ${session.sessionId})`); + await this._terminalService.safeDisposeTerminal(availableInstance); + if (availableInstance.isDisposed) { + this._removeTerminalFromTrackedSessions(availableInstance.instanceId); + } + if (!this._isArchiveCleanupCurrent(session.sessionId, cleanupGeneration)) { + await this._ensureActiveSessionTerminalAfterLateArchiveCleanup(session.sessionId); + return; + } } } + private _isArchiveCleanupCurrent(sessionId: string, generation: number): boolean { + return this._archivedSessionIds.has(sessionId) + && this._getTerminalOperationGeneration(sessionId) === generation; + } + + private async _ensureActiveSessionTerminalAfterLateArchiveCleanup(sessionId: string): Promise<void> { + const activeSession = this._sessionsService.activeSession.get(); + if (!activeSession + || activeSession.sessionId !== sessionId + || activeSession.isArchived.get() + || activeSession.loading.get() + || activeSession.worktreePending?.get()) { + return; + } + this._activeKey = undefined; + this._activeSessionId = undefined; + await this._onActiveSessionChanged(activeSession); + } + + private async _findUntrackedTerminalsForResource(resource: URI): Promise<ITerminalInstance[]> { + const result: ITerminalInstance[] = []; + for (const instance of this._terminalService.instances) { + if (!instance.shellLaunchConfig.attachPersistentProcess + || instance.shellLaunchConfig.hideFromUser + || this._isTerminalTracked(instance.instanceId) + || this._standaloneTerminalIds.has(instance.instanceId)) { + continue; + } + try { + if (isEqual(URI.file(await instance.getInitialCwd()), resource) + && !this._isTerminalTracked(instance.instanceId) + && !this._standaloneTerminalIds.has(instance.instanceId)) { + result.push(instance); + } + } catch { + // Ignore terminals whose cwd cannot be resolved. + } + } + return result; + } + async dumpTracking(): Promise<void> { console.log(`[SessionsTerminal] Active key: ${this._activeKey ?? '<none>'}`); console.log(`[SessionsTerminal] Session terminals: ${JSON.stringify([...this._sessionTerminals.entries()].map(([sessionId, terminalIds]) => [sessionId, [...terminalIds]]))}`); diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index f8ef2522fa43df..96be44c5a80dd5 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -56,6 +56,8 @@ type TestTerminalInstance = ITerminalInstance & { type TestActiveSession = IActiveSession & { loading: ReturnType<typeof observableValue<boolean>>; + isArchived: ReturnType<typeof observableValue<boolean>>; + worktreePending: ReturnType<typeof observableValue<boolean>>; }; function makeAgentSession(opts: { @@ -64,6 +66,7 @@ function makeAgentSession(opts: { providerType?: string; isArchived?: boolean; loading?: boolean; + worktreePending?: boolean; sessionId?: string; providerId?: string; }): TestActiveSession { @@ -74,7 +77,7 @@ function makeAgentSession(opts: { description: undefined, gitRepository: { uri: opts.repository ?? opts.worktree!, workTreeUri: opts.worktree, baseBranchName: undefined, gitHubInfo: constObservable(undefined) }, } : undefined; - const chat: IChat = { + const chat = { resource: URI.parse('file:///session'), createdAt: new Date(), title: observableValue('test.title', 'Test Session'), @@ -84,7 +87,7 @@ function makeAgentSession(opts: { modelId: observableValue('test.modelId', undefined), modelSource: observableValue('test.modelSource', undefined), mode: observableValue('test.mode', undefined), - isArchived: observableValue('test.isArchived', opts.isArchived ?? false), + isArchived: observableValue<boolean>('test.isArchived', opts.isArchived ?? false), isRead: observableValue('test.isRead', true), interactivity: observableValue('test.interactivity', ChatInteractivity.Full), checkpoints: observableValue('test.checkpoints', undefined), @@ -116,6 +119,7 @@ function makeAgentSession(opts: { modelId: chat.modelId, mode: chat.mode, loading: observableValue('test.loading', opts.loading ?? false), + worktreePending: observableValue('test.worktreePending', opts.worktreePending ?? false), isArchived: chat.isArchived, isRead: chat.isRead, lastTurnEnd: chat.lastTurnEnd, @@ -259,6 +263,8 @@ suite('SessionsTerminalContribution', () => { let showBackgroundCalls: number[]; let disposeOnCreatePaths: Set<string>; let defaultCwdCalls: (URI | undefined)[]; + let vetoSafeDispose: boolean; + let safeDisposeBarrier: DeferredPromise<void> | undefined; let logService: TestLogService; let allSessions: ISession[]; let sessionProviders: Map<string, ISessionsProvider>; @@ -280,6 +286,8 @@ suite('SessionsTerminalContribution', () => { showBackgroundCalls = []; disposeOnCreatePaths = new Set(); defaultCwdCalls = []; + vetoSafeDispose = false; + safeDisposeBarrier = undefined; logService = new TestLogService(); allSessions = []; sessionProviders = new Map(); @@ -343,6 +351,10 @@ suite('SessionsTerminalContribution', () => { focusCalls++; } override async safeDisposeTerminal(instance: ITerminalInstance): Promise<void> { + await safeDisposeBarrier?.p; + if (vetoSafeDispose) { + return; + } disposedInstances.push(instance); (instance as TestTerminalInstance)._testSetDisposed(true); terminalInstances.delete(instance.instanceId); @@ -491,6 +503,52 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(createdTerminals.length, 1); assert.strictEqual(createdTerminals[0].cwd.fsPath, worktreeUri.fsPath); + }); + + test('waits for the worktree before creating a terminal', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + worktreePending: true, + }); + + activeSessionObs.set(session, undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 0); + + session.worktreePending.set(false, undefined); + await tick(); + assert.deepStrictEqual(createdTerminals.map(terminal => terminal.cwd.fsPath), [worktreeUri.fsPath]); + }); + + test('disposes terminal creation that becomes stale while the worktree is pending', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + }); + const creationBarrier = new DeferredPromise<void>(); + terminalCreationBarriers.set(worktreeUri.fsPath, creationBarrier); + + activeSessionObs.set(session, undefined); + await tick(); + session.worktreePending.set(true, undefined); + await creationBarrier.complete(); + await tick(); + + session.worktreePending.set(false, undefined); + await tick(); + + assert.deepStrictEqual({ + created: createdTerminals.map(terminal => terminal.cwd.fsPath), + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + created: [worktreeUri.fsPath, worktreeUri.fsPath], + disposed: [1], + remaining: [2], + }); assert.strictEqual(defaultCwdCalls.at(-1)?.fsPath, worktreeUri.fsPath); }); @@ -618,6 +676,33 @@ suite('SessionsTerminalContribution', () => { }); }); + test('does not accept stale terminal creation after worktree readiness toggles back', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + }); + const creationBarrier = new DeferredPromise<void>(); + terminalCreationBarriers.set(worktreeUri.fsPath, creationBarrier); + + activeSessionObs.set(session, undefined); + await tick(); + session.worktreePending.set(true, undefined); + session.worktreePending.set(false, undefined); + await creationBarrier.complete(); + await tick(); + + assert.deepStrictEqual({ + created: createdTerminals.map(terminal => terminal.cwd.fsPath), + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + created: [worktreeUri.fsPath, worktreeUri.fsPath], + disposed: [1], + remaining: [2], + }); + }); + test('transfers all tracked terminals to a same-cwd replacement draft', async () => { const cwd = URI.file('/worktree'); const firstSession = makeAgentSession({ sessionId: 'test:first-draft', worktree: cwd, providerType: AgentSessionProviders.Background }); @@ -805,7 +890,7 @@ suite('SessionsTerminalContribution', () => { // --- onDidChangeSessions (archived) --- - test('hides (does not dispose) terminals when session is archived', async () => { + test('disposes terminals when session is archived', async () => { const worktreeUri = URI.file('/worktree'); await contribution.ensureTerminal(worktreeUri, false, makeAgentSession({ sessionId: 'test:archived-session', worktree: worktreeUri, providerType: AgentSessionProviders.Background })); // terminal 1 at /worktree @@ -817,7 +902,7 @@ suite('SessionsTerminalContribution', () => { activeSessionObs.set(otherSession, undefined); await tick(); - // Isolate the archive-driven hide from the visibility-switch hide above. + // Isolate archive cleanup from the visibility switch above. moveToBackgroundCalls.length = 0; const session = makeAgentSession({ @@ -829,8 +914,382 @@ suite('SessionsTerminalContribution', () => { onDidChangeSessions.fire({ added: [], removed: [], changed: [session] }); await tick(); - assert.strictEqual(disposedInstances.length, 0, 'archived session terminal must be hidden, not disposed'); - assert.deepStrictEqual(moveToBackgroundCalls, [1], 'archived session terminal should be moved to background'); + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + backgrounded: moveToBackgroundCalls, + }, { + disposed: [1], + backgrounded: [], + }); + }); + + test('disposes the active terminal when its session is archived', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ + sessionId: 'test:active-archived-session', + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + }); + await contribution.ensureTerminal(worktreeUri, false, session); + assert.strictEqual(activeInstanceId, 1); + + onDidChangeSessions.fire({ + added: [], + removed: [], + changed: [makeAgentSession({ + sessionId: session.sessionId, + isArchived: true, + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + })], + }); + await tick(); + + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + disposed: [1], + remaining: [], + }); + }); + + test('recreates the active restored terminal when archive cleanup completes late', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ + sessionId: 'test:restore-during-cleanup', + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + }); + activeSessionObs.set(session, undefined); + await tick(); + assert.strictEqual(activeInstanceId, 1); + safeDisposeBarrier = new DeferredPromise<void>(); + + session.isArchived.set(true, undefined); + onDidChangeSessions.fire({ added: [], removed: [], changed: [session] }); + await tick(); + session.isArchived.set(false, undefined); + await tick(); + await safeDisposeBarrier.complete(); + await tick(); + + assert.deepStrictEqual({ + created: createdTerminals.map(terminal => terminal.cwd.fsPath), + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + activeInstanceId, + }, { + created: [worktreeUri.fsPath, worktreeUri.fsPath], + disposed: [1], + remaining: [2], + activeInstanceId: 2, + }); + }); + + test('disposes an untracked restored terminal at the archived session worktree', async () => { + const worktreeUri = URI.file('/worktree'); + const restoredTerminal = makeTerminalInstance(nextInstanceId++, worktreeUri.fsPath); + restoredTerminal._testSetShellLaunchConfig({ attachPersistentProcess: { id: 1 } } as ITerminalInstance['shellLaunchConfig']); + terminalInstances.set(restoredTerminal.instanceId, restoredTerminal); + + onDidChangeSessions.fire({ + added: [], + removed: [], + changed: [makeAgentSession({ + sessionId: 'test:archived-session', + isArchived: true, + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + })], + }); + await tick(); + + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + disposed: [1], + remaining: [], + }); + }); + + test('does not dispose an untracked terminal at an archived repository cwd', async () => { + const repositoryUri = URI.file('/repository'); + const untrackedTerminal = makeTerminalInstance(nextInstanceId++, repositoryUri.fsPath); + untrackedTerminal._testSetShellLaunchConfig({ attachPersistentProcess: { id: 1 } } as ITerminalInstance['shellLaunchConfig']); + terminalInstances.set(untrackedTerminal.instanceId, untrackedTerminal); + + onDidChangeSessions.fire({ + added: [], + removed: [], + changed: [makeAgentSession({ + sessionId: 'test:archived-session', + isArchived: true, + repository: repositoryUri, + providerType: AgentSessionProviders.Background, + })], + }); + await tick(); + + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + disposed: [], + remaining: [1], + }); + }); + + test('does not dispose an untracked terminal whose cwd differs only by case from the archived worktree', async () => { + const worktreeUri = URI.file('/Worktree'); + const untrackedTerminal = makeTerminalInstance(nextInstanceId++, '/worktree'); + untrackedTerminal._testSetShellLaunchConfig({ attachPersistentProcess: { id: 1 } } as ITerminalInstance['shellLaunchConfig']); + terminalInstances.set(untrackedTerminal.instanceId, untrackedTerminal); + + onDidChangeSessions.fire({ + added: [], + removed: [], + changed: [makeAgentSession({ + sessionId: 'test:archived-session', + isArchived: true, + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + })], + }); + await tick(); + + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + disposed: [], + remaining: [1], + }); + }); + + test('does not cwd-match an untracked terminal for a remote archived worktree', async () => { + const remoteWorktree = toAgentHostUri(URI.file('C:\\repo\\worktree'), 'remote-windows'); + const untrackedTerminal = makeTerminalInstance(nextInstanceId++, 'C:\\repo\\worktree'); + untrackedTerminal._testSetShellLaunchConfig({ attachPersistentProcess: { id: 1 } } as ITerminalInstance['shellLaunchConfig']); + terminalInstances.set(untrackedTerminal.instanceId, untrackedTerminal); + + onDidChangeSessions.fire({ + added: [], + removed: [], + changed: [makeAgentSession({ + sessionId: 'test:archived-session', + isArchived: true, + worktree: remoteWorktree, + providerType: AgentSessionProviders.Background, + })], + }); + await tick(); + + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + disposed: [], + remaining: [1], + }); + }); + + test('does not dispose an active untracked restored terminal at the archived worktree', async () => { + const worktreeUri = URI.file('/worktree'); + const restoredTerminal = makeTerminalInstance(nextInstanceId++, worktreeUri.fsPath); + restoredTerminal._testSetShellLaunchConfig({ attachPersistentProcess: { id: 1 } } as ITerminalInstance['shellLaunchConfig']); + terminalInstances.set(restoredTerminal.instanceId, restoredTerminal); + activeInstanceId = restoredTerminal.instanceId; + + onDidChangeSessions.fire({ + added: [], + removed: [], + changed: [makeAgentSession({ + sessionId: 'test:archived-session', + isArchived: true, + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + })], + }); + await tick(); + + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + disposed: [], + remaining: [1], + }); + }); + + test('does not dispose an untracked restored terminal when another live session shares the worktree', async () => { + const worktreeUri = URI.file('/worktree'); + const archivedSession = makeAgentSession({ + sessionId: 'test:archived-session', + isArchived: true, + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + }); + const liveSession = makeAgentSession({ + sessionId: 'test:live-session', + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + }); + allSessions = [archivedSession, liveSession]; + const restoredTerminal = makeTerminalInstance(nextInstanceId++, worktreeUri.fsPath); + restoredTerminal._testSetShellLaunchConfig({ attachPersistentProcess: { id: 1 } } as ITerminalInstance['shellLaunchConfig']); + terminalInstances.set(restoredTerminal.instanceId, restoredTerminal); + + onDidChangeSessions.fire({ + added: [], + removed: [], + changed: [archivedSession], + }); + await tick(); + + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + disposed: [], + remaining: [1], + }); + }); + + test('does not dispose a manually created untracked terminal at the archived worktree', async () => { + const worktreeUri = URI.file('/worktree'); + const manualTerminal = makeTerminalInstance(nextInstanceId++, worktreeUri.fsPath); + terminalInstances.set(manualTerminal.instanceId, manualTerminal); + + onDidChangeSessions.fire({ + added: [], + removed: [], + changed: [makeAgentSession({ + sessionId: 'test:archived-session', + isArchived: true, + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + })], + }); + await tick(); + + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + disposed: [], + remaining: [1], + }); + }); + + test('disposes a terminal whose creation completes after its session is archived', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ sessionId: 'test:archived-during-create', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + const creationBarrier = new DeferredPromise<void>(); + terminalCreationBarriers.set(worktreeUri.fsPath, creationBarrier); + + activeSessionObs.set(session, undefined); + await tick(); + onDidChangeSessions.fire({ + added: [], + removed: [], + changed: [makeAgentSession({ + sessionId: session.sessionId, + isArchived: true, + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + })], + }); + await creationBarrier.complete(); + await tick(); + + assert.deepStrictEqual({ + created: createdTerminals.map(terminal => terminal.cwd.fsPath), + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + created: [worktreeUri.fsPath], + disposed: [1], + remaining: [], + }); + }); + + test('keeps an in-flight terminal tracked when archive disposal is vetoed', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ sessionId: 'test:archived-during-create', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + const creationBarrier = new DeferredPromise<void>(); + terminalCreationBarriers.set(worktreeUri.fsPath, creationBarrier); + vetoSafeDispose = true; + + activeSessionObs.set(session, undefined); + await tick(); + onDidChangeSessions.fire({ + added: [], + removed: [], + changed: [makeAgentSession({ + sessionId: session.sessionId, + isArchived: true, + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + })], + }); + await creationBarrier.complete(); + await tick(); + + const otherSessionTerminal = await contribution.ensureTerminal( + worktreeUri, + false, + makeAgentSession({ sessionId: 'test:other-session', worktree: worktreeUri, providerType: AgentSessionProviders.Background }), + ); + + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + otherSessionTerminal: otherSessionTerminal.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + disposed: [], + otherSessionTerminal: [2], + remaining: [1, 2], + }); + }); + + test('creates a fresh terminal when an archived session is restored and activated', async () => { + const worktreeUri = URI.file('/worktree'); + const archivedSession = makeAgentSession({ sessionId: 'test:archived-session', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + await contribution.ensureTerminal(worktreeUri, false, archivedSession); + + const otherSession = makeAgentSession({ sessionId: 'test:other-session', worktree: URI.file('/other'), providerType: AgentSessionProviders.Background }); + activeSessionObs.set(otherSession, undefined); + await tick(); + + const archived = makeAgentSession({ + sessionId: archivedSession.sessionId, + isArchived: true, + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + }); + onDidChangeSessions.fire({ added: [], removed: [], changed: [archived] }); + await tick(); + + const restored = makeAgentSession({ + sessionId: archivedSession.sessionId, + worktree: worktreeUri, + providerType: AgentSessionProviders.Background, + }); + activeSessionObs.set(restored, undefined); + await tick(); + onDidChangeSessions.fire({ added: [], removed: [], changed: [restored] }); + + assert.deepStrictEqual({ + created: createdTerminals.map(terminal => terminal.cwd.fsPath), + disposed: disposedInstances.map(instance => instance.instanceId), + remaining: [...terminalInstances.keys()], + }, { + created: [worktreeUri.fsPath, URI.file('/other').fsPath, worktreeUri.fsPath], + disposed: [1], + remaining: [2, 3], + }); }); test('does not hide or dispose terminals when session is not archived', async () => { @@ -878,7 +1337,7 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(moveToBackgroundCalls.length, 0); }); - test('hides terminals when archived session has only a repository (no worktree)', async () => { + test('disposes terminals when archived session has only a repository (no worktree)', async () => { const repoUri = URI.file('/repo'); const session = makeAgentSession({ sessionId: 'test:repo-session', repository: repoUri, providerType: AgentSessionProviders.Background, isArchived: false }); activeSessionObs.set(session, undefined); @@ -900,8 +1359,13 @@ suite('SessionsTerminalContribution', () => { onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] }); await tick(); - assert.strictEqual(disposedInstances.length, 0, 'archived repo-only session terminal must be hidden, not disposed'); - assert.deepStrictEqual(moveToBackgroundCalls, [1]); + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + backgrounded: moveToBackgroundCalls, + }, { + disposed: [1], + backgrounded: [], + }); }); test('does not hide the terminal at the active session cwd when archiving (just-opened terminal is protected)', async () => { @@ -926,7 +1390,7 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(moveToBackgroundCalls.length, 0, 'terminal at the active session cwd must not be hidden'); }); - test('does not re-hide a newly-opened terminal when an already-archived session is re-emitted', async () => { + test('does not re-dispose a newly-opened terminal when an already-archived session is re-emitted', async () => { // Mirrors the "every new terminal keeps dying" repro (#313510, #318645): // the provider keeps archived sessions cached and re-emits them in `changed` // on every sync. The archive cleanup must only run on the first archived @@ -939,11 +1403,11 @@ suite('SessionsTerminalContribution', () => { moveToBackgroundCalls.length = 0; - // First archive event hides the terminal at the archived cwd (not active). + // First archive event disposes the terminal owned by the archived session. onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] }); await tick(); - assert.strictEqual(disposedInstances.length, 0); - assert.deepStrictEqual(moveToBackgroundCalls, [1]); + assert.deepStrictEqual(disposedInstances.map(instance => instance.instanceId), [1]); + assert.deepStrictEqual(moveToBackgroundCalls, []); // The user opens a new terminal at the same cwd, then moves focus elsewhere. await contribution.ensureTerminal(worktreeUri, false, makeAgentSession({ sessionId: 'test:later-session', worktree: worktreeUri, providerType: AgentSessionProviders.Background })); // terminal 3 at /worktree, active @@ -957,8 +1421,8 @@ suite('SessionsTerminalContribution', () => { // keeps it alive: the re-emit must be a no-op so the newly-opened terminal survives. onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] }); await tick(); - assert.strictEqual(disposedInstances.length, 0, 're-emitted archived session must not dispose any terminal'); - assert.strictEqual(moveToBackgroundCalls.length, 0, 're-emitted archived session must not re-hide the newly-opened terminal'); + assert.deepStrictEqual(disposedInstances.map(instance => instance.instanceId), [1], 're-emitted archived session must not dispose the later terminal'); + assert.strictEqual(moveToBackgroundCalls.length, 0, 're-emitted archived session must not affect the newly-opened terminal'); }); test('does not hide terminals for a session that was already archived when the contribution started', async () => { @@ -1055,7 +1519,7 @@ suite('SessionsTerminalContribution', () => { assert.ok(terminalInstances.has(2), 'the surviving session terminal should remain'); }); - test('hides only the archived session terminal when sessions share a cwd', async () => { + test('disposes only the archived session terminal when sessions share a cwd', async () => { const worktreeUri = URI.file('/worktree'); await contribution.ensureTerminal(worktreeUri, false, makeAgentSession({ sessionId: 'test:live', worktree: worktreeUri, providerType: AgentSessionProviders.Background })); await contribution.ensureTerminal(worktreeUri, false, makeAgentSession({ sessionId: 'test:archived', worktree: worktreeUri, providerType: AgentSessionProviders.Background })); @@ -1073,8 +1537,15 @@ suite('SessionsTerminalContribution', () => { onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] }); await tick(); - assert.strictEqual(disposedInstances.length, 0, 'terminal should be hidden, not disposed'); - assert.deepStrictEqual(moveToBackgroundCalls, [2], 'only the archived session terminal should be hidden'); + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + backgrounded: moveToBackgroundCalls, + remaining: [...terminalInstances.keys()], + }, { + disposed: [2], + backgrounded: [], + remaining: [1], + }); }); test('closes terminal when the only session at a cwd is removed even if other live sessions exist elsewhere', async () => { @@ -1322,7 +1793,7 @@ suite('SessionsTerminalContribution', () => { // --- Hidden tool terminals (hideFromUser) --- - test('does not hide hidden tool terminals when session is archived', async () => { + test('does not dispose hidden tool terminals when session is archived', async () => { const worktreeUri = URI.file('/worktree'); await contribution.ensureTerminal(worktreeUri, false, makeAgentSession({ sessionId: 'test:regular-session', worktree: worktreeUri, providerType: AgentSessionProviders.Background })); // terminal 1 (regular) at /worktree @@ -1348,9 +1819,15 @@ suite('SessionsTerminalContribution', () => { onDidChangeSessions.fire({ added: [], removed: [], changed: [session] }); await tick(); - // The regular terminal should be hidden, but the tool terminal must survive untouched. - assert.strictEqual(disposedInstances.length, 0, 'archived session terminal must be hidden, not disposed'); - assert.deepStrictEqual(moveToBackgroundCalls, [1], 'only the regular terminal should be hidden, not the tool terminal'); + assert.deepStrictEqual({ + disposed: disposedInstances.map(instance => instance.instanceId), + backgrounded: moveToBackgroundCalls, + toolTerminalDisposed: toolTerminal.isDisposed, + }, { + disposed: [1], + backgrounded: [], + toolTerminalDisposed: false, + }); }); test('does not dispose hidden tool terminals when session is removed', async () => {