From a728eb8da4e181598c560cedb86788ec7b60a9f9 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Sat, 3 Oct 2026 00:36:09 +0200 Subject: [PATCH 1/2] agent host: refactor: adopt experimental AHP canvas channels Replace the VS Code-specific canvas snapshot and source-resolution transport with the merged Agent Host Protocol canvas channel. Chat state advertises canvas resource references while live state is synchronized through experimental canvas channels. Preserve the AHP 0.9 archived catalog field alongside 0.10 status flags, and gate chat read actions to 0.10 for negotiated compatibility. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostProtocolClient.ts | 104 +--------- src/vs/platform/agentHost/common/agent.ts | 40 +--- .../common/agentHostExtensionProtocol.ts | 67 +----- .../common/agentHostSubscriptionService.ts | 5 +- .../platform/agentHost/common/agentService.ts | 18 +- .../agentHost/common/ahpJsonlLogger.ts | 52 ++--- src/vs/platform/agentHost/common/canvasUri.ts | 35 ++++ .../common/meta/agentHostCanvasesMeta.ts | 6 - .../meta/vscode/agentHostCanvasesMeta.ts | 13 -- .../common/state/agentSubscription.ts | 26 ++- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/action-origin.generated.ts | 26 ++- .../common/state/protocol/actions.ts | 1 + .../state/protocol/channels-canvas/actions.ts | 26 +++ .../state/protocol/channels-canvas/reducer.ts | 27 +++ .../state/protocol/channels-canvas/state.ts | 55 +++++ .../state/protocol/channels-chat/actions.ts | 39 ++++ .../state/protocol/channels-chat/reducer.ts | 10 + .../state/protocol/channels-chat/state.ts | 9 + .../protocol/channels-root/notifications.ts | 1 + .../protocol/channels-session/actions.ts | 3 + .../state/protocol/channels-session/state.ts | 17 +- .../common/state/protocol/common/actions.ts | 10 +- .../common/state/protocol/common/state.ts | 5 +- .../common/state/protocol/reducers.ts | 1 + .../agentHost/common/state/protocol/state.ts | 1 + .../common/state/protocol/version/registry.ts | 6 +- .../agentHost/common/state/sessionActions.ts | 1 + .../agentHost/common/state/sessionState.ts | 5 +- .../electron-browser/localAgentHostService.ts | 33 +-- .../agentHost/node/agentHostStateManager.ts | 53 ++++- .../platform/agentHost/node/agentService.ts | 39 ++-- .../agentHost/node/agentSideEffects.ts | 4 + .../agentHost/node/copilot/copilotAgent.ts | 12 +- .../node/copilot/copilotAgentSession.ts | 91 +++++---- .../agentHost/node/protocolServerHandler.ts | 118 +---------- .../test/common/agentSubscription.test.ts | 59 +++++- .../test/common/ahpJsonlLogger.test.ts | 48 ++--- .../agentHostProtocolClient.test.ts | 110 ++++------ .../test/node/agentHostStateManager.test.ts | 51 ++++- .../agentHost/test/node/agentService.test.ts | 21 ++ .../test/node/copilotAgentSession.test.ts | 106 ++++------ .../test/node/protocolServerHandler.test.ts | 132 +----------- .../copilotMockLlm.integrationTest.ts | 21 +- src/vs/sessions/SESSIONS.md | 2 +- .../electron-browser/sessionCanvasEditor.ts | 34 ++-- .../electron-browser/sessionCanvasService.ts | 48 ++--- .../test/common/sessionCanvas.test.ts | 12 +- .../sessionCanvasService.test.ts | 41 ++-- .../test/browser/desktopStrategies.test.ts | 6 +- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 4 +- .../browser/agentHostSessionChangesets.ts | 3 +- .../browser/baseAgentHostSessionsProvider.ts | 157 ++++++-------- .../browser/localAgentHostSessionsProvider.ts | 3 +- .../localAgentHostSessionsProvider.test.ts | 192 ++++++++---------- .../services/sessions/common/session.ts | 24 +-- 56 files changed, 950 insertions(+), 1085 deletions(-) create mode 100644 src/vs/platform/agentHost/common/canvasUri.ts delete mode 100644 src/vs/platform/agentHost/common/meta/agentHostCanvasesMeta.ts delete mode 100644 src/vs/platform/agentHost/common/meta/vscode/agentHostCanvasesMeta.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/channels-canvas/actions.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/channels-canvas/reducer.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/channels-canvas/state.ts diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index feacd80ccdae76..4e802554f27cff 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -9,7 +9,6 @@ import { DeferredPromise, TimeoutTimer } from '../../../base/common/async.js'; import { CancellationError } from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable, IReference, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; -import { equals } from '../../../base/common/objects.js'; import { Schemas } from '../../../base/common/network.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; @@ -19,9 +18,9 @@ import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { ConfigurationTarget, ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; -import { AgentCanvasAvailability, AgentSession, IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification, type IAgentCanvas, type IAgentCanvasSnapshot } from '../common/agent.js'; -import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostCanvases, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; -import { AgentHostCanvasesChangedNotification, agentHostCanvasesChangedParamsValidator, ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ImportSessionExtensionMethod, isValidAgentHostCanvasesChangedParams, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, ReportAgentHostFirstResponseExtensionMethod, ReportChatUserInteractionExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, resolveAgentHostCanvasSourceResultValidator, ResolveAgentHostCanvasSourceExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostCanvases, supportsAgentHostChatStateFile, supportsAgentHostDevContainers, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostMcpAuthenticationRequest } from '../common/agentHostExtensionProtocol.js'; +import { AgentSession, IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; +import { ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ImportSessionExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, ReportAgentHostFirstResponseExtensionMethod, ReportChatUserInteractionExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostChatStateFile, supportsAgentHostDevContainers, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostMcpAuthenticationRequest } from '../common/agentHostExtensionProtocol.js'; import { McpAuthRequiredReason } from '../common/state/protocol/channels-session/state.js'; import { supportsAgentHostTiming, supportsChatUserInteractionTiming } from '../common/meta/agentHostTimingMeta.js'; import { readCodexSessionModel } from '../common/meta/codexSessionModel.js'; @@ -34,7 +33,7 @@ import { AGENT_HOST_SCHEME, agentHostAuthority, createAgentHostResourceUriMapper import { AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../common/agentHostResourceService.js'; import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest, JsonRpcResponse } from '../common/state/protocol/messages.js'; import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; -import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isDefaultChatUri, parseChatUri, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; +import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isDefaultChatUri, isSessionStatusArchived, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCompatibility.js'; import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; @@ -278,23 +277,6 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect : undefined; } - private readonly _canvasSnapshots = new Map(); - private readonly _onDidChangeCanvases = this._register(new Emitter()); - private readonly _canvasService: IAgentHostCanvases = { - onDidChange: this._onDidChangeCanvases.event, - getSnapshots: () => [...this._canvasSnapshots.values()], - resolveSource: (chat, instanceId, revision) => this._resolveCanvasSource(chat, instanceId, revision), - }; - - get canvases(): IAgentHostCanvases | undefined { - return this._resourceIdentity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY - && this._state.kind !== AgentHostClientState.Closed - && this._state.kind !== AgentHostClientState.Incompatible - && supportsAgentHostCanvases(this._initializeResult.get()) - ? this._canvasService - : undefined; - } - private readonly _subscriptionManager: AgentSubscriptionManager; private readonly _onDidAction = this._register(new Emitter()); @@ -692,7 +674,6 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._logService.warn('[AgentHostProtocolClient] Dispose callback failed', error); } } - this._clearCanvasSnapshots(); this._handleClose(connectionDisposedError(this._address)); super.dispose(); } @@ -1086,7 +1067,6 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } this._logService.info(`[RemoteAgentHostProtocol] Server forgot client ${this._clientId}; initializing a fresh connection.`); - this._clearCanvasSnapshots(); const initializeResult = await this._dispatchRequest('initialize', { channel: ROOT_STATE_URI, protocolVersions: [...CLIENT_SUPPORTED_PROTOCOL_VERSIONS], @@ -1839,27 +1819,6 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect await this._sendRequest('disposeChat', { channel: chat.toString() }); } - private async _resolveCanvasSource(chat: URI, instanceId: string, revision: number): Promise { - if (this.canvases !== this._canvasService) { - throw new Error('Agent Host canvases are not available for this connection.'); - } - const snapshot = this._canvasSnapshots.get(chat.toString()); - const canvas = snapshot?.canvases.find(canvas => canvas.instanceId === instanceId); - if (!canvas || canvas.revision !== revision || canvas.availability !== AgentCanvasAvailability.Ready) { - throw new Error(`Canvas '${instanceId}' is not available at revision ${revision}.`); - } - const result = await this._sendExtensionRequest(ResolveAgentHostCanvasSourceExtensionMethod, { - chat: chat.toString(), - instanceId, - revision, - }); - const validated = resolveAgentHostCanvasSourceResultValidator.validate(result); - if (validated.error || !validated.content.url.trim()) { - throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host returned an invalid canvas source.'); - } - return validated.content.url; - } - /** * Create a new terminal on the remote agent host. */ @@ -1917,7 +1876,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect kind: s.defaultChat === chat.resource || isDefaultChatUri(chat.resource) ? 'default' : 'peer', origin: chat.origin, ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), - ...(chat.archived === true ? { archived: true } : {}), + ...(isSessionStatusArchived(chat.status) || chat.archived === true ? { archived: true } : {}), ...(chat.changes !== undefined ? { changes: chat.changes } : {}), })), // Carry durable host provenance for sessions first materialized from a listing. @@ -2094,10 +2053,6 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._logService.warn(`[RemoteAgentHostProtocol] Received response for unknown request id ${msg.id}`); } } else if (isJsonRpcNotification(msg)) { - if ((msg as { method: string }).method === AgentHostCanvasesChangedNotification) { - this._handleCanvasSnapshot((msg as { params?: unknown }).params); - return; - } if (this._devContainerService.handleNotification(msg.method, msg.params)) { return; } @@ -2147,55 +2102,6 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } } - private _handleCanvasSnapshot(params: unknown): void { - if (this._resourceIdentity !== LOCAL_AGENT_HOST_RESOURCE_IDENTITY) { - return; - } - const initializeResult = this._initializeResult.get(); - if (initializeResult && !supportsAgentHostCanvases(initializeResult)) { - return; - } - const validated = agentHostCanvasesChangedParamsValidator.validate(params); - if (validated.error || !isValidAgentHostCanvasesChangedParams(validated.content)) { - this._logService.warn('[AgentHostProtocolClient] Ignoring invalid canvas snapshot.'); - return; - } - let chat: URI; - try { - chat = URI.parse(validated.content.chat, true); - } catch { - this._logService.warn('[AgentHostProtocolClient] Ignoring canvas snapshot with an invalid chat URI.'); - return; - } - if (!parseChatUri(chat)) { - this._logService.warn('[AgentHostProtocolClient] Ignoring canvas snapshot for a non-chat URI.'); - return; - } - const canvases: readonly IAgentCanvas[] = validated.content.canvases; - const key = chat.toString(); - const previous = this._canvasSnapshots.get(key); - if (previous && equals(previous.canvases, canvases)) { - return; - } - if (canvases.length === 0) { - if (!previous) { - return; - } - this._canvasSnapshots.delete(key); - } else { - this._canvasSnapshots.set(key, { chat, canvases }); - } - this._onDidChangeCanvases.fire({ chat, canvases }); - } - - private _clearCanvasSnapshots(): void { - const snapshots = [...this._canvasSnapshots.values()]; - this._canvasSnapshots.clear(); - for (const snapshot of snapshots) { - this._onDidChangeCanvases.fire({ chat: snapshot.chat, canvases: [] }); - } - } - private _handleFatalClose(error: ProtocolError, reason?: AgentHostTransportFailureReason): void { this._onDidFatalClose.fire(error); this._handleClose(error, reason); diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index afc0ac77131280..61ae3983b9c710 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -17,6 +17,7 @@ import type { AgentHostClientType } from './agentHostClientInfo.js'; import type { CodexModelProvider, IAgentTurnTelemetryCorrelation, IAgentHostClientTelemetryContext, IAgentProviderSendStageRecorder, IAgentProviderTurnTelemetryContext } from './agentHostTelemetry.js'; import type { AgentPermissionDecisionSource } from './meta/agentPermissionResponseMeta.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; +import type { CanvasState } from './state/protocol/channels-canvas/state.js'; import { ProtectedResourceMetadata, type BackgroundWork, type Changeset, type ChatInteractivity, type ChatOrigin, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition, ChangesSummary } from './state/protocol/state.js'; import type { ActionOrigin, AuthRequiredParams, SessionAction, ChatAction } from './state/sessionActions.js'; import { ChatInputResponseKind, ChatOriginKind, SessionStatus, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, type AgentCapabilities, type ClientPluginCustomization, type Customization, type ErrorInfo, type ISessionFolderPickerDecision, type Message, type PendingMessage, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; @@ -707,31 +708,6 @@ export interface IAgentChatDataChange { readonly providerData: string; } -export const AgentCanvasAvailability = { - Ready: 'ready', - Unavailable: 'unavailable', -} as const; - -export type AgentCanvasAvailability = typeof AgentCanvasAvailability[keyof typeof AgentCanvasAvailability]; - -/** A live model-opened canvas projected by an agent provider. */ -export interface IAgentCanvas { - readonly instanceId: string; - readonly extensionId: string; - readonly extensionName?: string; - readonly canvasId: string; - readonly title?: string; - readonly status?: string; - readonly revision: number; - readonly availability: AgentCanvasAvailability; -} - -/** Full replacement of the live canvas collection for one exact chat. */ -export interface IAgentCanvasSnapshot { - readonly chat: URI; - readonly canvases: readonly IAgentCanvas[]; -} - /** A legacy concrete chat backing enumerated by {@link IAgent.listLegacyChatBackings} for migration. */ export interface IAgentLegacyChat { /** The concrete chat's channel URI (see {@link buildChatUri}). */ @@ -901,8 +877,6 @@ export interface IAgentChats { /** Reconstruct the turns for `chat` (used on restore). */ getMessages(chat: URI, context: AgentChatOperationContext): Promise; - /** Resolve the current source of a live model-opened canvas. */ - resolveCanvasSource?(chat: URI, instanceId: string, revision: number, context: AgentChatOperationContext): Promise; } export interface IAgentResolveChatConfigParams { @@ -946,6 +920,7 @@ export interface IAgentModelInfo { */ export type AgentSignal = | IAgentActionSignal + | IAgentCanvasSignal | IAgentModelCallCompletedSignal | IAgentModelCallFinishedSignal | IAgentToolPendingConfirmationSignal @@ -972,6 +947,14 @@ export interface IAgentActionSignal { readonly parentToolCallId?: string; } +/** Materializes or updates a chat-owned live canvas channel. */ +export interface IAgentCanvasSignal { + readonly kind: 'canvas'; + readonly chat: URI; + readonly resource: URI; + readonly state: CanvasState; +} + /** Reports one completed upstream model response for host-owned turn telemetry. */ export interface IAgentModelCallCompletedSignal { readonly kind: 'model_call_completed'; @@ -1314,9 +1297,6 @@ export interface IAgent { /** Fires when an opaque chat backing changes and must be persisted again. */ readonly onDidChangeChatData: Event; - /** Full-replacement live canvas snapshots for exact chats owned by this provider. */ - readonly onDidChangeCanvases?: Event; - /** Fires when the provider creates a chat, such as a delegated subagent. */ readonly onDidSpawnChat: Event; diff --git a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts index 60ef616a7c8d7b..33efc6fa0b7393 100644 --- a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts +++ b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { vArray, vBoolean, vEnum, vNumber, vObj, vOptionalProp, vString, type ValidatorType } from '../../../base/common/validation.js'; +import { vBoolean, vEnum, vObj, vOptionalProp, vString, type ValidatorType } from '../../../base/common/validation.js'; import type { IDevContainerAgentHostConnectResult } from './devContainerAgentHost.js'; import type { AgentHostDebugLogsArtifactKind, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult } from './agentService.js'; import type { InitializeResult } from './state/protocol/common/commands.js'; @@ -15,10 +15,8 @@ import { AgentHostTimingCapabilityMetaKey, ChatUserInteractionCapability } from import type { IAgentHostFirstResponseDiagnostic } from './otel/agentHostTiming.js'; import type { IChatUserInteractionTiming } from '../../otel/common/chatUserInteraction.js'; import { AgentHostAutonomousAutomationsCapabilityMetaKey } from './meta/agentHostAutomationsMeta.js'; -import { AgentHostCanvasesCapabilityMetaKey } from './meta/agentHostCanvasesMeta.js'; export { supportsAgentHostArtifactRemoval } from './meta/agentHostArtifactRemovalMeta.js'; -export { supportsAgentHostCanvases } from './meta/agentHostCanvasesMeta.js'; export { supportsAgentHostDevContainers } from './meta/agentHostDevContainersMeta.js'; export const DevContainerIsDockerAvailableExtensionMethod = 'vscode/devContainers/isDockerAvailable'; @@ -58,10 +56,6 @@ export const RemoveSessionArtifactExtensionMethod = 'vscode/removeSessionArtifac export const ImportSessionExtensionMethod = 'vscode/importSession'; export const ReportAgentHostFirstResponseExtensionMethod = 'vscode/reportAgentHostFirstResponse'; export const ReportChatUserInteractionExtensionMethod = 'vscode/reportChatUserInteraction'; -export const AgentHostCanvasesChangedNotification = 'vscode/canvases/v1/changed'; -export const ResolveAgentHostCanvasSourceExtensionMethod = 'vscode/canvases/v1/resolveSource'; -export const AGENT_HOST_CANVAS_LIMIT = 8; - const AgentHostChatStateFileCapabilityMetaKey = 'vscode.getAgentHostSessionStateFile.chat'; const AgentHostDetachedWorktreeCapabilityMetaKey = 'vscode.detachedWorktrees'; @@ -74,7 +68,6 @@ export interface IAgentHostExtensionInitializeResultMeta extends Record; - -export function isValidAgentHostCanvasesChangedParams(params: IAgentHostCanvasesChangedParams): boolean { - if (!params.chat.trim() || params.canvases.length > AGENT_HOST_CANVAS_LIMIT) { - return false; - } - const instanceIds = new Set(); - for (const canvas of params.canvases) { - if (!canvas.instanceId.trim() - || !canvas.extensionId.trim() - || !canvas.canvasId.trim() - || !Number.isSafeInteger(canvas.revision) - || canvas.revision <= 0 - || instanceIds.has(canvas.instanceId)) { - return false; - } - instanceIds.add(canvas.instanceId); - } - return true; -} - export const collectAgentHostDebugLogsParamsValidator = vObj({ session: vOptionalProp(vString()), chat: vOptionalProp(vString()), @@ -221,10 +166,6 @@ export interface IAgentHostExtensionCommandMap { /** `data` is base64; at most `AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES` decoded bytes. */ result: { data: string; eof: boolean }; }; - [ResolveAgentHostCanvasSourceExtensionMethod]: { - params: ValidatorType; - result: ValidatorType; - }; } export interface IAgentHostExtensionNotificationMap { @@ -234,10 +175,6 @@ export interface IAgentHostExtensionNotificationMap { [DevContainerOutputNotification]: ValidatorType; } -export interface IAgentHostCanvasExtensionNotificationMap { - [AgentHostCanvasesChangedNotification]: IAgentHostCanvasesChangedParams; -} - export interface IAgentHostWorkspaceTrustRequest { readonly workspace: string; readonly trustedParent?: string; diff --git a/src/vs/platform/agentHost/common/agentHostSubscriptionService.ts b/src/vs/platform/agentHost/common/agentHostSubscriptionService.ts index e41cb281742970..6f7f70ee9befdb 100644 --- a/src/vs/platform/agentHost/common/agentHostSubscriptionService.ts +++ b/src/vs/platform/agentHost/common/agentHostSubscriptionService.ts @@ -8,6 +8,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import { parseAnnotationsUri } from './annotationsUri.js'; import { parseChangesetUri, parseFolderChangesetOwnerUri } from './changesetUri.js'; import { parseNonPtyShellTerminalUri } from './nonPtyShellTerminalUri.js'; +import { parseCanvasChatUri } from './canvasUri.js'; import { parseChatUri, parseSubagentSessionUri } from './state/sessionState.js'; export const IAgentHostSubscriptionService = createDecorator('agentHostSubscriptionService'); @@ -32,8 +33,10 @@ export function resolveAgentHostSession(resource: URI): URI { const annotationsSession = parseAnnotationsUri(resourceString)?.sessionUri; const folderSession = parseFolderChangesetOwnerUri(resourceString)?.sessionUri; const chatSession = parseChatUri(resourceString)?.session; + const canvasChat = parseCanvasChatUri(resource); + const canvasSession = canvasChat ? parseChatUri(canvasChat)?.session : undefined; const terminalSession = parseNonPtyShellTerminalUri(resource)?.session.toString(); - let session = URI.parse(changesetSession ?? annotationsSession ?? folderSession ?? chatSession ?? terminalSession ?? resourceString); + let session = URI.parse(changesetSession ?? annotationsSession ?? folderSession ?? chatSession ?? canvasSession ?? terminalSession ?? resourceString); let subagent; while ((subagent = parseSubagentSessionUri(session))) { session = subagent.parentSession; diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 0648b9bdf1ed04..a11b4896998e47 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -28,7 +28,7 @@ import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomati import type { ActionEnvelope, ClientAutomationAction, ClientAutomationRunAction, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction, ClientChangesetAction } from './state/sessionActions.js'; import type { ContentEncoding, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js'; import { ComponentToState, StateComponents, type RootState } from './state/sessionState.js'; -import { type AgentProvider, CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, type AuthenticateParams, type AuthenticateResult, type IAgentCanvasSnapshot, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentPluginUninstallRequest, type IAgentSessionMetadata, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IMcpNotification, type IAgentHostNetworkEndpoint, type IAgentHostManagedSettingsSnapshot } from './agent.js'; +import { type AgentProvider, CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, type AuthenticateParams, type AuthenticateResult, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentPluginUninstallRequest, type IAgentSessionMetadata, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IMcpNotification, type IAgentHostNetworkEndpoint, type IAgentHostManagedSettingsSnapshot } from './agent.js'; // ---- Provider-model re-exports (compatibility) ------------------------------ // New provider code imports these from agent.ts. @@ -880,9 +880,6 @@ export interface IAgentService { /** Dispose an additional chat created via {@link createChat}. */ disposeChat(session: URI, chat: URI): Promise; - /** Resolve the current source of a live canvas owned by a chat. */ - resolveCanvasSource(chat: URI, instanceId: string, revision: number): Promise; - /** Resolve the dynamic configuration schema for creating a session. */ resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise; @@ -954,9 +951,6 @@ export interface IAgentService { */ readonly onMcpNotification: Event; - /** Full-replacement live canvas snapshots aggregated across capable providers. */ - readonly onDidChangeCanvases: Event; - /** Gracefully shut down all sessions and the underlying client. */ shutdown(): Promise; @@ -1114,9 +1108,6 @@ export interface IAgentConnection { /** Available for capable hosts, including while reconnecting; absent after permanent disconnection. */ readonly devContainerService?: IDevContainerAgentHostMainService; - /** Available only for the local VS Code canvas extension contract. */ - readonly canvases?: IAgentHostCanvases; - readonly clientId: string; readonly resourceUris: IAgentHostResourceUriMapper; @@ -1289,13 +1280,6 @@ export interface IAgentConnection { watchResource(params: CreateResourceWatchParams): Promise; } -/** Client projection of the local VS Code canvas extension contract. */ -export interface IAgentHostCanvases { - readonly onDidChange: Event; - getSnapshots(): readonly IAgentCanvasSnapshot[]; - resolveSource(chat: URI, instanceId: string, revision: number): Promise; -} - export const IAgentHostService = createDecorator('agentHostService'); /** diff --git a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts index 7e3398e300eef4..262a372bbecae9 100644 --- a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts +++ b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts @@ -11,7 +11,7 @@ import { joinPath } from '../../../base/common/resources.js'; import { isUriComponents, URI, UriComponents } from '../../../base/common/uri.js'; import { IFileService, IFileStatWithMetadata } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { ResolveAgentHostCanvasSourceExtensionMethod } from './agentHostExtensionProtocol.js'; +import { AHP_CANVAS_SCHEME } from './canvasUri.js'; export type AhpLogDirection = 'c2s' | 's2c'; @@ -58,17 +58,11 @@ const MAX_LOG_LINE_LENGTH = 1024 * 1024; // length. Generous enough to keep messages useful for debugging. const MAX_LOGGED_STRING_LENGTH = 16 * 1024; const REDACTED_CANVAS_SOURCE = ''; -const MAX_TRACKED_CANVAS_SOURCE_REQUESTS = 1024; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -function isJsonRpcId(value: unknown): value is string | number { - return typeof value === 'string' || typeof value === 'number'; -} - - export class AhpJsonlLogger extends Disposable { private readonly _directory: URI; @@ -82,8 +76,6 @@ export class AhpJsonlLogger extends Disposable { private _pending: VSBuffer[] = []; private _drainScheduled = false; private _folderCreated: Promise | undefined; - private readonly _canvasSourceRequestIds = new Set(); - private _redactAllUrlResponses = false; constructor( private readonly _options: IAhpJsonlLoggerOptions, @@ -112,7 +104,7 @@ export class AhpJsonlLogger extends Disposable { transport: this._options.transport, ...(typeof byteLength === 'number' ? { byteLength } : {}), }; - const entry = { ...this._redactCanvasSource(message, dir), _ahpLog: meta }; + const entry = { ...message, _ahpLog: meta }; // Fast path: serialize once. The vast majority of messages are small, so // we only pay a single stringify and use its length to decide whether the // rare oversized-message path below is needed. @@ -129,35 +121,6 @@ export class AhpJsonlLogger extends Disposable { this._scheduleDrain(); } - private _redactCanvasSource(message: object, dir: AhpLogDirection): object { - if (!isRecord(message)) { - return message; - } - if (dir === 'c2s' && message.method === ResolveAgentHostCanvasSourceExtensionMethod && isJsonRpcId(message.id)) { - if (!this._canvasSourceRequestIds.has(message.id) && this._canvasSourceRequestIds.size >= MAX_TRACKED_CANVAS_SOURCE_REQUESTS) { - this._redactAllUrlResponses = true; - } else { - this._canvasSourceRequestIds.add(message.id); - } - return message; - } - if (dir !== 's2c') { - return message; - } - const trackedCanvasSourceResponse = isJsonRpcId(message.id) && this._canvasSourceRequestIds.delete(message.id); - const result = message.result; - if ((!trackedCanvasSourceResponse && !this._redactAllUrlResponses) || !isRecord(result) || typeof result.url !== 'string') { - return message; - } - return { - ...message, - result: { - ...result, - url: REDACTED_CANVAS_SOURCE, - }, - }; - } - async flush(): Promise { // Pending entries always have a drain scheduled (see _scheduleDrain), so // awaiting the queue is sufficient to flush everything submitted before @@ -293,6 +256,17 @@ function stringifyAhpLogEntryTruncated(value: unknown, maxStringLength: number): * would otherwise be required to find every URI in a message payload. */ function _ahpReplacer(this: unknown, _key: string, value: unknown): unknown { + if (isRecord(value)) { + if (value.type === 'canvas/stateChanged' && isRecord(value.canvas) && value.canvas.url !== undefined) { + return { ...value, canvas: { ...value.canvas, url: REDACTED_CANVAS_SOURCE } }; + } + const isCanvasResource = typeof value.resource === 'string' + ? value.resource.toLowerCase().startsWith(`${AHP_CANVAS_SCHEME}:`) + : URI.isUri(value.resource) && value.resource.scheme === AHP_CANVAS_SCHEME; + if (isCanvasResource && isRecord(value.state) && value.state.url !== undefined) { + return { ...value, state: { ...value.state, url: REDACTED_CANVAS_SOURCE } }; + } + } if ( value && typeof value === 'object' diff --git a/src/vs/platform/agentHost/common/canvasUri.ts b/src/vs/platform/agentHost/common/canvasUri.ts new file mode 100644 index 00000000000000..9bd651256facd5 --- /dev/null +++ b/src/vs/platform/agentHost/common/canvasUri.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { URI } from '../../../base/common/uri.js'; +import { parseChatUri } from './state/sessionState.js'; + +export const AHP_CANVAS_SCHEME = 'ahp-canvas'; + +/** Builds a local host canvas channel with its owning chat and lifetime identifier. */ +export function buildCanvasUri(chat: URI, id: string): URI { + return URI.from({ + scheme: AHP_CANVAS_SCHEME, + path: `/${encodeBase64(VSBuffer.fromString(chat.toString()), false, true)}/${id}`, + }); +} + +/** Reads ownership from a locally constructed canvas channel, not a remote host's URI. */ +export function parseCanvasChatUri(resource: URI): URI | undefined { + if (resource.scheme !== AHP_CANVAS_SCHEME || resource.authority !== '' || resource.query !== '' || resource.fragment !== '') { + return undefined; + } + const [, encodedChat, id, extra] = resource.path.split('/'); + if (!encodedChat || !id || extra !== undefined) { + return undefined; + } + try { + const chat = URI.parse(decodeBase64(encodedChat).toString(), true); + return parseChatUri(chat) ? chat : undefined; + } catch { + return undefined; + } +} diff --git a/src/vs/platform/agentHost/common/meta/agentHostCanvasesMeta.ts b/src/vs/platform/agentHost/common/meta/agentHostCanvasesMeta.ts deleted file mode 100644 index 5a2e399eb7b422..00000000000000 --- a/src/vs/platform/agentHost/common/meta/agentHostCanvasesMeta.ts +++ /dev/null @@ -1,6 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export { AgentHostCanvasesCapabilityMetaKey, supportsAgentHostCanvases } from './vscode/agentHostCanvasesMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentHostCanvasesMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentHostCanvasesMeta.ts deleted file mode 100644 index e9e4f16290a545..00000000000000 --- a/src/vs/platform/agentHost/common/meta/vscode/agentHostCanvasesMeta.ts +++ /dev/null @@ -1,13 +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 type { InitializeResult } from '../../state/protocol/common/commands.js'; - -export const AgentHostCanvasesCapabilityMetaKey = 'vscode.canvases.v1'; - -/** Whether the local host supports the versioned VS Code canvas extension contract. */ -export function supportsAgentHostCanvases(result: InitializeResult | undefined): boolean { - return result?._meta?.[AgentHostCanvasesCapabilityMetaKey] === true; -} diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 0c75caa3a50564..3ba7486ed82eb4 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -12,6 +12,8 @@ import { URI } from '../../../../base/common/uri.js'; import { ActionEnvelope, ActionType, type AutomationAction, type AutomationRunAction, ChangesetAction, ChatAction, AnnotationsAction, ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, ClientChangesetAction, IRootConfigChangedAction, SessionAction, StateAction, isChangesetAction, isChatAction, isAnnotationsAction, isSessionAction } from './sessionActions.js'; import { automationReducer, automationRunReducer, changesetReducer, chatReducer, annotationsReducer, rootReducer, sessionReducer } from './sessionReducers.js'; import { terminalReducer } from './protocol/reducers.js'; +import { canvasReducer } from './protocol/channels-canvas/reducer.js'; +import type { CanvasState } from './protocol/channels-canvas/state.js'; import type { RootAction, SessionAction as IProtocolSessionAction, ChatAction as IProtocolChatAction, TerminalAction } from './protocol/action-origin.generated.js'; import type { AnnotationsState, AutomationRunState, AutomationState, ChangesetState, ChatState, RootState, SessionState, TerminalState } from './protocol/state.js'; import type { IStateSnapshot } from './sessionProtocol.js'; @@ -691,6 +693,26 @@ export class AutomationRunSubscription extends BaseAgentSubscription { + + constructor( + private readonly _resource: string, + clientId: string, + log: (msg: string) => void, + ) { + super(clientId, log); + } + + protected override _applyReducer(state: CanvasState, action: StateAction): CanvasState { + return action.type === ActionType.CanvasStateChanged ? canvasReducer(state, action, this._log) : state; + } + + protected override _isRelevantEnvelope(envelope: ActionEnvelope): boolean { + return envelope.channel === this._resource && envelope.action.type === ActionType.CanvasStateChanged; + } +} + /** * Subscription to a changeset at an expanded changeset URI (e.g. * `/changeset/session`). @@ -787,7 +809,7 @@ export class ChangesetStateSubscription extends BaseAgentSubscription void): CanvasState { + if (action.type === ActionType.CanvasStateChanged) { + return action.canvas; + } + + (log ?? console.warn)(`Unhandled action type: ${JSON.stringify(action)}`); + return state; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-canvas/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-canvas/state.ts new file mode 100644 index 00000000000000..3ac60f536ab66d --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-canvas/state.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import type { URI } from '../common/state.js'; + +/** + * Stable reference to a subscribable canvas channel. + * + * Chat state intentionally carries only this reference so the experimental + * canvas presentation model can evolve without changing the stable chat + * channel shape. + * + * @category Canvas State + */ +export interface CanvasReference { + /** Canvas channel URI. Subscribe to this resource for the full state. */ + resource: URI; +} + +/** + * Full state for one live canvas, returned when a client subscribes to its + * `ahp-canvas:` URI. + * + * The client already knows the subscribed resource, so the state does not + * redundantly carry its channel URI. + * + * @category Canvas State + */ +export interface CanvasState { + /** Stable caller-supplied instance identifier. */ + instanceId: string; + /** Owning extension/provider identifier. */ + extensionId: string; + /** Owning extension display name, when available. */ + extensionName?: string; + /** Provider-local canvas type identifier. */ + canvasId: string; + /** Provider-supplied title, when available. */ + title?: string; + /** Provider-supplied status text, when available. */ + status?: string; + /** + * Current absolute HTTP(S) source URL; absent when the live source is unavailable. + * Hosts MUST clear this field when the provider becomes unavailable. + * + * Source URLs MUST be redacted from diagnostic logs and MUST NOT be reused + * from persisted state after a provider or host restart. + */ + url?: URI; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts index 164d503e43316f..afc1efa5e4253f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts @@ -8,6 +8,7 @@ import { ActionType } from '../common/actions.js'; import type { StringOrMarkdown, FileEditCollection, UsageInfo, URI } from '../common/state.js'; +import type { CanvasReference } from '../channels-canvas/state.js'; import type { Changeset } from '../channels-changeset/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type BackgroundWork, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ErrorResponsePart, type ToolCallContributor, type ToolCallRiskAssessment, type ToolInput, type Turn } from './state.js'; @@ -592,6 +593,21 @@ export interface ChatChangesetsChangedAction { changesets: Changeset[] | undefined; } +/** + * The live canvas channels exposed by this chat changed. + * + * Replaces {@link ChatState.canvases | `state.canvases`} entirely. Set to + * `undefined` to clear the collection. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatCanvasesChangedAction { + type: ActionType.ChatCanvasesChanged; + /** New canvas channel references, or `undefined` to clear the collection. */ + canvases: CanvasReference[] | undefined; +} + /** * A working directory was added to this chat's * {@link ChatState.workingDirectories} subset. @@ -818,6 +834,27 @@ export interface ChatDraftChangedAction { draft?: Message; } +/** + * The read state of the chat changed. + * + * Dispatched by a client to mark any known chat, including the owning + * session's default chat, as read (e.g. after viewing it) or unread. This + * changes only the addressed chat; it does not change the read state of its + * owning session or sibling chats. Use `session/isReadChanged` only to change + * the owning session's independent read state. After accepting this action, + * the host also synchronizes the addressed chat's `ChatSummary.status` and + * `SessionChatSummary.status` projections. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatIsReadChangedAction { + type: ActionType.ChatIsReadChanged; + /** Whether the chat has been read */ + isRead: boolean; +} + /** * The archived state of the chat changed. * @@ -916,6 +953,7 @@ export type ChatAction = | ChatBackgroundWorkRemovedAction | ChatMovableChangedAction | ChatChangesetsChangedAction + | ChatCanvasesChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction | ChatUsageAction @@ -926,6 +964,7 @@ export type ChatAction = | ChatPendingMessageRemovedAction | ChatQueuedMessagesReorderedAction | ChatDraftChangedAction + | ChatIsReadChangedAction | ChatIsArchivedChangedAction | ChatInputRequestedAction | ChatInputAnswerChangedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index 9f504a3650d099..1ca0e5ecb59b99 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -463,6 +463,13 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st : stateWithoutChangesets; } + case ActionType.ChatCanvasesChanged: { + const { canvases: _omit, ...stateWithoutCanvases } = state; + return action.canvases + ? { ...stateWithoutCanvases, canvases: action.canvases } + : stateWithoutCanvases; + } + // ── Working Directories ─────────────────────────────────────────────── case ActionType.ChatWorkingDirectorySet: { @@ -932,6 +939,9 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st case ActionType.ChatDraftChanged: return { ...state, draft: action.draft }; + case ActionType.ChatIsReadChanged: + return { ...state, status: withStatusFlag(state.status, SessionStatus.IsRead, action.isRead) }; + case ActionType.ChatIsArchivedChanged: return { ...state, status: withStatusFlag(state.status, SessionStatus.IsArchived, action.isArchived) }; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index 88ab0fe3902da1..22245f38b831a8 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -9,6 +9,7 @@ import type { ModelSelection } from '../channels-root/state.js'; import type { AgentSelection, ChangesSummary, McpAuthRequirement, SessionStatus } from '../channels-session/state.js'; import type { Changeset } from '../channels-changeset/state.js'; +import type { CanvasReference } from '../channels-canvas/state.js'; import type { ContentRef, ErrorInfo, FileEdit, FileEditCollection, StringOrMarkdown, TextRange, TextSelection, URI, UsageInfo } from '../common/state.js'; // ─── Chat State ────────────────────────────────────────────────────────────── @@ -101,6 +102,14 @@ export interface ChatState { * chat channel. */ backgroundWork?: BackgroundWork[]; + /** + * Live canvases currently exposed by this chat. + * + * Entries intentionally contain only subscribable channel references. + * Clients subscribe to each resource for the experimental presentation + * state, including its current live source URL. + */ + canvases?: CanvasReference[]; // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts index 4032839fbc0690..d27834126721b8 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts @@ -140,6 +140,7 @@ export interface SessionSummaryChangedParams { * * Identity fields (`resource`, `provider`, `createdAt`) never change and * MUST be omitted by senders; receivers SHOULD ignore them if present. + * When `chats` is present, it replaces the complete compact chat catalog. */ changes: Partial; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index 7cb8987777b538..2b9a74b91e3e68 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -75,6 +75,9 @@ export interface SessionChatRemovedAction { * SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}. * * Mirrors the root-channel `root/sessionSummaryChanged` notification. + * When `changes.status` changes, the host MUST project that exact value into + * the matching `SessionChatSummary.status` field and publish + * the updated compact chat catalog through `root/sessionSummaryChanged`. * * @category Session Actions * @version 1 diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index 6c641788e529ff..002feb33c40c23 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -529,12 +529,19 @@ export interface SessionChatSummary { */ interactivity?: ChatInteractivity; /** - * Whether this chat has been archived independently of its owning session - * (see `chat/isArchivedChanged`). + * Current chat status, matching {@link ChatSummary.status}. * - * Generic clients use this to group or filter archived chats in session - * lists without subscribing to the session channel. Absence means the - * chat is not archived. + * Includes the activity bits and the orthogonal {@link SessionStatus.IsRead} + * and {@link SessionStatus.IsArchived} flags. Generic clients use these bits + * to present read, unread, or archived chats in session lists without + * subscribing to the session or chat channel. Absence means the host did + * not provide the status; clients MUST treat it as unknown, not as unread + * or unarchived. + */ + status?: SessionStatus; + /** + * @deprecated AHP 0.9 compatibility. Use {@link SessionChatSummary.status} + * with {@link SessionStatus.IsArchived} in AHP 0.10 and newer. */ archived?: boolean; /** diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index cefda092fed2c5..c3001f836c3124 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -12,7 +12,9 @@ import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerm import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionChatsReorderedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionWorkingDirectoryReplacedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionMcpServerBackgroundRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; -import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatBackgroundWorkSetAction, ChatBackgroundWorkRemovedAction, ChatMovableChangedAction, ChatChangesetsChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatIsArchivedChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; +import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatBackgroundWorkSetAction, ChatBackgroundWorkRemovedAction, ChatMovableChangedAction, ChatChangesetsChangedAction, ChatCanvasesChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatIsReadChangedAction, ChatIsArchivedChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; + +import type { CanvasStateChangedAction } from '../channels-canvas/actions.js'; import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetFilesReviewChangedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; @@ -63,6 +65,8 @@ export const enum ActionType { ChatBackgroundWorkRemoved = 'chat/backgroundWorkRemoved', ChatMovableChanged = 'chat/movableChanged', ChatChangesetsChanged = 'chat/changesetsChanged', + ChatCanvasesChanged = 'chat/canvasesChanged', + CanvasStateChanged = 'canvas/stateChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', SessionTitleChanged = 'session/titleChanged', @@ -80,6 +84,7 @@ export const enum ActionType { ChatPendingMessageRemoved = 'chat/pendingMessageRemoved', ChatQueuedMessagesReordered = 'chat/queuedMessagesReordered', ChatDraftChanged = 'chat/draftChanged', + ChatIsReadChanged = 'chat/isReadChanged', ChatIsArchivedChanged = 'chat/isArchivedChanged', ChatInputRequested = 'chat/inputRequested', ChatInputAnswerChanged = 'chat/inputAnswerChanged', @@ -227,6 +232,8 @@ export type StateAction = | ChatBackgroundWorkRemovedAction | ChatMovableChangedAction | ChatChangesetsChangedAction + | ChatCanvasesChangedAction + | CanvasStateChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction | ChatUsageAction @@ -235,6 +242,7 @@ export type StateAction = | ChatPendingMessageRemovedAction | ChatQueuedMessagesReorderedAction | ChatDraftChangedAction + | ChatIsReadChangedAction | ChatIsArchivedChangedAction | ChatInputRequestedAction | ChatInputAnswerChangedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/common/state.ts b/src/vs/platform/agentHost/common/state/protocol/common/state.ts index 26b8ca0f199627..9fcf3df529ae89 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/state.ts @@ -13,6 +13,7 @@ import type { ChangesetState } from '../channels-changeset/state.js'; import type { ResourceWatchState } from '../channels-resource-watch/state.js'; import type { AnnotationsState } from '../channels-annotations/state.js'; import type { ChatState } from '../channels-chat/state.js'; +import type { CanvasState } from '../channels-canvas/state.js'; import type { AutomationState } from '../channels-automation/state.js'; import type { AutomationRunState } from '../channels-automation-run/state.js'; @@ -348,10 +349,10 @@ export interface ErrorInfo { * @category Common Types */ export interface Snapshot { - /** The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) */ + /** The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, `ahp-chat:/`, or `ahp-canvas:/`) */ resource: URI; /** The current state of the resource */ - state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState; + state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | CanvasState | AutomationState | AutomationRunState; /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ fromSeq: number; } diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts index 8004b19cf7899f..f783f0c6deb778 100644 --- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -9,6 +9,7 @@ export { rootReducer } from './channels-root/reducer.js'; export { sessionReducer } from './channels-session/reducer.js'; export { chatReducer } from './channels-chat/reducer.js'; +export { canvasReducer } from './channels-canvas/reducer.js'; export { terminalReducer } from './channels-terminal/reducer.js'; export { changesetReducer } from './channels-changeset/reducer.js'; export { annotationsReducer } from './channels-annotations/reducer.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 1c2205dffb7a54..63b832db24dab1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -10,6 +10,7 @@ export * from './common/state.js'; export * from './channels-root/state.js'; export * from './channels-session/state.js'; export * from './channels-chat/state.js'; +export * from './channels-canvas/state.js'; export * from './channels-terminal/state.js'; export * from './channels-changeset/state.js'; export * from './channels-annotations/state.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 21d80d49bf59a3..5b288f0fd8ed0c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -16,7 +16,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.9.0'; +export const PROTOCOL_VERSION = '0.10.0'; /** * Every protocol version a client built from this source tree is willing @@ -35,6 +35,7 @@ export const PROTOCOL_VERSION = '0.9.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '0.10.0', '0.9.0', '0.8.0', '0.7.0', @@ -134,6 +135,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatBackgroundWorkRemoved]: '0.9.0', [ActionType.ChatMovableChanged]: '0.9.0', [ActionType.ChatChangesetsChanged]: '0.9.0', + [ActionType.ChatCanvasesChanged]: '0.10.0', + [ActionType.CanvasStateChanged]: '0.10.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', [ActionType.ChatUsage]: '0.4.0', @@ -142,6 +145,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatPendingMessageRemoved]: '0.4.0', [ActionType.ChatQueuedMessagesReordered]: '0.4.0', [ActionType.ChatDraftChanged]: '0.5.0', + [ActionType.ChatIsReadChanged]: '0.10.0', [ActionType.ChatIsArchivedChanged]: '0.9.0', [ActionType.ChatInputRequested]: '0.4.0', [ActionType.ChatInputAnswerChanged]: '0.4.0', diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index 96297212274d89..64ddb7e9f97aef 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -41,6 +41,7 @@ export { type ChatTurnCancelledAction, type ChatTurnCompleteAction, type ChatTurnStartedAction, + type CanvasStateChangedAction, type ChatUsageAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index e6673c4855277c..9d19c69acc0211 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -41,6 +41,7 @@ import { type ChangesetState, type ChatState, type ChatSummary, + type CanvasState, type ErrorInfo, type ErrorResponsePart, type PendingMessage, @@ -77,7 +78,7 @@ export { SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, TurnState, type ActiveTurn, type AgentCustomization, type AgentCapabilities, type AgentInfo, type AgentSelection, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type AnnotationsSummary, type Changeset, type ChangesetFile, - type ChangesetOperation, type ChangesetState, type ChatState, type ChatSummary, type ChatOrigin, type ChildCustomization, type ClientPluginCustomization, type ConfigPropertySchema, + type ChangesetOperation, type ChangesetState, type ChatState, type ChatSummary, type ChatOrigin, type CanvasReference, type CanvasState, type ChildCustomization, type ClientPluginCustomization, type ConfigPropertySchema, type ConfigSchema, type ContentRef, type Customization, type CustomizationDegradedState, type CustomizationErrorState, type CustomizationLoadedState, type CustomizationLoadingState, type CustomizationLoadState, type DirectoryCustomization, type ErrorInfo, type HookCustomization, type FileEdit as ISessionFileDiff, type ToolResultEmbeddedResourceContent as IToolResultBinaryContent, type MarkdownResponsePart, type McpServerCustomization, type MessageAttachment, @@ -814,6 +815,7 @@ export const enum StateComponents { Annotations, AutomationCatalog, AutomationRun, + Canvas, } export type ComponentToState = { @@ -825,6 +827,7 @@ export type ComponentToState = { [StateComponents.Annotations]: AnnotationsState; [StateComponents.AutomationCatalog]: AutomationState; [StateComponents.AutomationRun]: AutomationRunState; + [StateComponents.Canvas]: CanvasState; }; // ---- Default chat URI helpers ---------------------------------------------- diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 0895c538890a7d..7819a7b99df9f8 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -35,37 +35,7 @@ import type { IAgentHostMcpAuthenticationRequest } from '../common/agentHostExte import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; import type { IAgentHostFirstResponseDiagnostic } from '../common/otel/agentHostTiming.js'; import type { IChatUserInteractionTiming } from '../../otel/common/chatUserInteraction.js'; -import { - AgentHostAhpJsonlLoggingSettingId, - type AgentHostDebugLogsArtifactKind, - AgentHostIpcChannels, - AgentHostOTelPolicyIpcChannel, - AgentHostRestartIpcChannel, - AgentHostWillRestartIpcChannel, - AgentSession, - IAgentCreateChatRequestOptions, - IAgentCreateSessionConfig, - IAgentHostInspectInfo, - type IAgentHostDebugLogsArtifact, - type IAgentHostCanvases, - IAgentHostManagementService, - IAgentHostManagedSettingsDiagnostics, - IAgentHostNetworkDiagnosticsInfo, - IAgentHostNetworkFetchResult, - type IAgentHostOTelSettings, - type IAgentHostOTelPolicyReadiness, - IAgentHostService, - IAgentHostSocketInfo, - IAgentPluginUninstallRequest, - IAgentResolveSessionConfigParams, - IAgentSessionConfigCompletionsParams, - IAgentSessionMetadata, - AuthenticateParams, - AuthenticateResult, - IMcpNotification, - readAgentHostOTelPolicySettings, - type IAgentHostDebugLogsChunk, -} from '../common/agentService.js'; +import { AgentHostAhpJsonlLoggingSettingId, type AgentHostDebugLogsArtifactKind, AgentHostIpcChannels, AgentHostOTelPolicyIpcChannel, AgentHostRestartIpcChannel, AgentHostWillRestartIpcChannel, AgentSession, IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, type IAgentHostDebugLogsArtifact, IAgentHostManagementService, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type IAgentHostOTelSettings, type IAgentHostOTelPolicyReadiness, IAgentHostService, IAgentHostSocketInfo, IAgentPluginUninstallRequest, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification, readAgentHostOTelPolicySettings, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; import type { IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import type { IActiveSubscriptionInfo, IAgentSubscription } from '../common/state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, ContentEncoding, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; @@ -154,7 +124,6 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos readonly clientId = generateUuid(); get resourceUris() { return this._protocolClient?.resourceUris ?? identityAgentHostResourceUriMapper; } - get canvases(): IAgentHostCanvases | undefined { return this._protocolClient?.canvases; } private readonly _clientStore = this._register(new MutableDisposable()); private readonly _managementConnection = this._register(new LocalAgentHostManagementConnection()); diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 136e4dfd952f17..81e894047f79fa 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -13,7 +13,7 @@ import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams, type SessionSummaryChanges } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, ChatOriginKind, IHostBuildInfo, SessionStatus, ResponsePartKind, ToolCallStatus } from '../common/state/sessionState.js'; +import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, isSessionStatusArchived, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, ChatOriginKind, IHostBuildInfo, SessionStatus, ResponsePartKind, ToolCallStatus } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { parseChangesetUri } from '../common/changesetUri.js'; @@ -21,6 +21,10 @@ import { buildAnnotationsUri, isAnnotationsUri, parseAnnotationsUri } from '../c import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOptions } from './agentHostChangesetStateCache.js'; import { ChangesSummary, ChatInteractivity, type ChatOrigin, type SessionChatSummary } from '../common/state/protocol/state.js'; import { arrayEquals, structuralEquals } from '../../../base/common/equals.js'; +import { canvasReducer } from '../common/state/protocol/channels-canvas/reducer.js'; +import type { CanvasState } from '../common/state/protocol/channels-canvas/state.js'; +import { parseCanvasChatUri } from '../common/canvasUri.js'; +import { URI as ResourceURI } from '../../../base/common/uri.js'; import { preserveProviderBackedRootConfigValues } from '../common/agentCustomizationSettings.js'; import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { readEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -271,6 +275,7 @@ export class AgentHostStateManager extends Disposable { private readonly _annotations = new Map(); private _automationCatalog: AutomationState | undefined; private readonly _automationRuns = new Map(); + private readonly _canvases = new Map(); /** * Active turns per session, keyed by session URI string with the value @@ -506,6 +511,8 @@ export class AgentHostStateManager extends Disposable { title: chat.title, origin: chat.origin, ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), + status: chat.status, + ...(isSessionStatusArchived(chat.status) ? { archived: true } : {}), ...(chat.changes !== undefined ? { changes: chat.changes } : {}), })); this._sessionSummaryChats.set(chats, result); @@ -769,6 +776,10 @@ export class AgentHostStateManager extends Disposable { fromSeq: this._serverSeq, }; } + const canvas = this._canvases.get(resource); + if (canvas) { + return { resource, state: canvas.state, fromSeq: this._serverSeq }; + } // Changeset URIs are nested under their session URI; check them // before falling back to the session map so a session whose URI @@ -836,6 +847,32 @@ export class AgentHostStateManager extends Disposable { return this._automationRuns.get(resource); } + /** Installs canvas state before its chat advertises the channel reference. */ + setCanvasState(chat: string, resource: string, state: CanvasState): void { + const owner = this._chatEntries.get(chat); + if (!owner?.valid || !owner.state) { + this._logService.warn(`[AgentHostStateManager] Canvas state for unavailable chat: ${chat}`); + return; + } + if (parseCanvasChatUri(ResourceURI.parse(resource))?.toString() !== chat) { + throw new Error(`Canvas channel does not belong to chat: ${resource}`); + } + const previous = this._canvases.get(resource); + if (previous?.state === state) { + return; + } + this._canvases.set(resource, { chat, state }); + this.dispatchServerAction(resource, { type: ActionType.CanvasStateChanged, canvas: state }); + } + + private _pruneChatCanvases(chat: string, resources: ReadonlySet): void { + for (const [resource, canvas] of this._canvases) { + if (canvas.chat === chat && !resources.has(resource)) { + this._canvases.delete(resource); + } + } + } + /** Read-only accessor for callers that only need to inspect a changeset (not subscribe). */ getChangesetState(changeset: URI): ChangesetState | undefined { return this._changesets.get(changeset); @@ -1829,6 +1866,7 @@ export class AgentHostStateManager extends Disposable { // ---- Internal ----------------------------------------------------------- private _invalidateChatEntry(chat: URI): void { + this._pruneChatCanvases(chat, new Set()); const entry = this._chatEntries.get(chat); if (entry) { entry.valid = false; @@ -1944,6 +1982,9 @@ export class AgentHostStateManager extends Disposable { if (chat && chatEntry && sessionKey !== undefined) { const newChat = chatReducer(chat, chatAction, this._log); chatEntry.state = newChat; + if (chatAction.type === ActionType.ChatCanvasesChanged) { + this._pruneChatCanvases(channel, new Set(newChat.canvases?.map(canvas => canvas.resource) ?? [])); + } this._onChatStateChanged(sessionKey, channel, chat, newChat); resultingState = newChat; } else { @@ -1951,6 +1992,16 @@ export class AgentHostStateManager extends Disposable { } } + if (action.type === ActionType.CanvasStateChanged) { + const canvas = this._canvases.get(channel); + if (!canvas) { + this._logService.warn(`[AgentHostStateManager] Action for unknown canvas: ${channel}`); + return undefined; + } + canvas.state = canvasReducer(canvas.state, action, this._log); + resultingState = canvas.state; + } + if (isChangesetAction(action)) { const changesetAction = action as ChangesetAction; const key = channel; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 470f385d1fc72b..0364191ca0d227 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -23,7 +23,7 @@ import { localize } from '../../../nls.js'; import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileOperationResult, type FileChangesEvent } from '../../files/common/files.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, CODEX_AGENT_PROVIDER_ID, IAgent, type IAgentAdoptedWorktree, type IAgentCanvasSnapshot, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatRequestOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentLegacyChat, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPluginUninstallRequest, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, type IAgentSessionChatMetadata, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; +import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, CODEX_AGENT_PROVIDER_ID, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatRequestOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentLegacyChat, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPluginUninstallRequest, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, type IAgentSessionChatMetadata, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; import { type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; import { ISessionDatabase, ISessionDataService, ISessionStorageAccessCounts, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; @@ -34,6 +34,7 @@ import { parseChangesetUri, parseFolderChangesetOwnerUri } from '../common/chang import { ActionType, ActionEnvelope, AuthRequiredReason, INotification, isAnnotationsAction, isPassiveSessionMetadataAction, isSessionAction, type ChatAction, type ClientAutomationAction, type ClientAutomationRunAction, type IIsArchivedChangedAction, type IIsReadChangedAction, type IRootConfigChangedAction, type SessionAction, type SessionWorkingDirectoryAction, type TerminalAction, type ClientAnnotationsAction, type ClientChangesetAction } from '../common/state/sessionActions.js'; import { resolveSessionWorkingDirectoryAction } from '../common/state/sessionWorkingDirectories.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult, SessionConfigPropertySchema } from '../common/state/protocol/commands.js'; +import { AHP_CANVAS_SCHEME } from '../common/canvasUri.js'; import type { AutomationCapabilities } from '../common/state/protocol/common/commands.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; @@ -623,9 +624,6 @@ export class AgentService extends Disposable implements IAgentService { /** Protocol: fires for MCP server-originated notifications routed over `mcp://` channels. */ readonly onMcpNotification: IAgentService['onMcpNotification']; - private readonly _onDidChangeCanvases = this._register(new Emitter()); - readonly onDidChangeCanvases = this._onDidChangeCanvases.event; - /** Authoritative state manager for the sessions process protocol. */ private readonly _stateManager: AgentHostStateManager; @@ -1401,9 +1399,6 @@ export class AgentService extends Disposable implements IAgentService { })); this._setupChatDiscoveryForProvider(provider); subscriptions.add(provider.onDidChangeChatData(e => this._onChatDataChanged(e))); - if (provider.onDidChangeCanvases) { - subscriptions.add(provider.onDidChangeCanvases(snapshot => this._onDidChangeCanvases.fire(snapshot))); - } if (provider.onDidChangeChatHistory) { subscriptions.add(provider.onDidChangeChatHistory(event => { void this._chatHistoryRefreshes.queue(event.chat.toString(), () => this._refreshChatHistory(provider, event.chat, event.turns)).catch(error => { @@ -1484,19 +1479,6 @@ export class AgentService extends Disposable implements IAgentService { return this._changesetOperationService.invokeChangesetOperation(params); } - async resolveCanvasSource(chat: URI, instanceId: string, revision: number): Promise { - const parsed = parseChatUri(chat); - if (!parsed) { - throw new Error(`Cannot resolve a canvas source for invalid chat URI '${chat.toString()}'`); - } - const session = URI.parse(parsed.session); - const provider = this._providerService.getProviderForSession(session); - if (!provider?.chats.resolveCanvasSource) { - throw new Error(`Agent provider does not support canvas source resolution for '${chat.toString()}'`); - } - return provider.chats.resolveCanvasSource(chat, instanceId, revision, this._chatContext(session, chat)); - } - // ---- MCP `mcp://` channel routing -------------------------------------- async handleMcpRequest(channel: string, method: string, params: Record | undefined): Promise { @@ -2209,7 +2191,7 @@ export class AgentService extends Disposable implements IAgentService { kind: summary.defaultChat === chat.resource || isDefaultChatUri(chat.resource) ? 'default' : 'peer', origin: chat.origin, ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), - ...(chat.archived === true ? { archived: true } : {}), + ...(isSessionStatusArchived(chat.status) || chat.archived === true ? { archived: true } : {}), ...(chat.changes !== undefined ? { changes: chat.changes } : {}), })); } @@ -6306,6 +6288,21 @@ export class AgentService extends Disposable implements IAgentService { } async subscribe(resource: URI, clientId: string, isActive?: () => boolean): Promise { + if (resource.scheme === AHP_CANVAS_SCHEME) { + if (this._store.isDisposed || (isActive && !isActive())) { + throw new Error(`Subscription cancelled: ${resource.toString()}`); + } + await this._sessionResidency.waitForRelease(resource); + if (this._store.isDisposed || (isActive && !isActive())) { + throw new Error(`Subscription cancelled: ${resource.toString()}`); + } + const snapshot = this._stateManager.getSnapshot(resource.toString()); + if (!snapshot) { + throw new ProtocolError(AhpErrorCodes.NotFound, `Canvas is no longer available: ${resource.toString()}`); + } + this.addSubscriber(resource, clientId); + return snapshot; + } this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`); const resourceStr = resource.toString(); const subscribe = async (telemetry: IAgentHostSessionOpenTelemetryScope): Promise => { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index ed2ff57f490352..1b8f37c3ca712c 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -671,6 +671,10 @@ export class AgentSideEffects extends Disposable { * once the `subagent_started` arrives. */ private _handleAgentSignal(agent: IAgent, signal: AgentSignal): void { + if (signal.kind === 'canvas') { + this._stateManager.setCanvasState(signal.chat.toString(), signal.resource.toString(), signal.state); + return; + } if (signal.kind === 'subagent_started') { this._handleSubagentStarted(signal.chat.toString(), signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription, signal.taskPrompt, signal.parentToolCallId, signal.taskModelSource); this._drainPendingSubagentSignals(signal.chat.toString(), signal.toolCallId); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index f984d23fc50cc5..c35ab92523df25 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -47,7 +47,7 @@ import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliCo import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, agentHostProxyConfigSchema, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; -import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, COPILOT_CLI_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, type IAgentCanvasSnapshot, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentPluginUninstallRequest, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; +import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, COPILOT_CLI_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentPluginUninstallRequest, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel } from '../../common/autoModeTiers.js'; import { AUTO_MODEL_ID, isAutoModel } from './modelIdentifiers.js'; @@ -711,8 +711,6 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _onDidChatProgress = this._register(new Emitter()); readonly onDidChatProgress = this._onDidChatProgress.event; - private readonly _onDidChangeCanvases = this._register(new Emitter()); - readonly onDidChangeCanvases = this._onDidChangeCanvases.event; private readonly _authenticationRequired = observableValueOpts | undefined>( { owner: this, equalsFn: structuralEquals }, undefined, @@ -3746,13 +3744,6 @@ export class CopilotAgent extends Disposable implements IAgent { return this._changeAgent(chatUri, agent, context); }, getMessages: (chat: URI, context: URI | IAgentChatContext): Promise => this._getChatMessages(chat, context), - resolveCanvasSource: (chat: URI, instanceId: string, revision: number): Promise => { - const session = this._findChatByUri(chat); - if (!session) { - throw new Error(`Cannot resolve canvas source: chat '${chat.toString()}' has no live Copilot session`); - } - return Promise.resolve(session.resolveCanvasSource(instanceId, revision)); - }, }; getTurnDiagnosticSnapshot(chat: URI, turnId: string): IAgentTurnDiagnosticSnapshot { @@ -6061,7 +6052,6 @@ export class CopilotAgent extends Disposable implements IAgent { ...(identity?.resource ? { resource: identity.resource } : {}), rawSessionId: launchPlan.sessionId, onDidSessionProgress: this._onDidChatProgress, - onDidChangeCanvases: this._onDidChangeCanvases, sessionLauncher: this._sessionLauncher, launchPlan, shellManager: launchPlan.shellManager, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 76bcd1f8b58207..f5ab9e57490f49 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -46,8 +46,7 @@ import { readCopilotShellAttachment, toCopilotBackgroundShellMeta } from '../../ import { getSessionSandboxConfig } from '../sessionSandbox.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostMcpToolRoutingEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder } from '../../common/agentHostTelemetry.js'; -import { AgentCanvasAvailability, AgentSession, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, subagentChatTitle, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type IAgentCanvas, type IAgentCanvasSnapshot, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentTelemetryContext, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; -import { AGENT_HOST_CANVAS_LIMIT } from '../../common/agentHostExtensionProtocol.js'; +import { AgentSession, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, subagentChatTitle, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentTelemetryContext, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; import { isReasoningEffortLevel } from '../../common/reasoningEffort.js'; import { agentModelConfigurationMetaKey, IAgentRuntimeModelConfiguration, readAgentRuntimeModelConfiguration } from '../../common/meta/agentModelConfigurationMeta.js'; import { ObservedTokenUsage } from './observedTokenUsage.js'; @@ -68,7 +67,8 @@ import { ISessionDatabase, ISessionDataService, MAX_TERMINAL_OUTPUT_BYTES } from import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { BackgroundWorkKind, MessageAttachmentKind, ToolCallContributorKind, type BackgroundWork, type FileEdit, type MessageAttachment, type ToolCallContributor } from '../../common/state/protocol/state.js'; import { ActionType, isChatAction, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; -import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, isSubagentSession, parseRequiredSessionUriFromChatUri, type Customization, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type ITurnTokenTotal, type UsageInfo, type IContextAttributionData, type ISessionPromptCacheState } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, isSubagentSession, parseRequiredSessionUriFromChatUri, type CanvasState, type Customization, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type ITurnTokenTotal, type UsageInfo, type IContextAttributionData, type ISessionPromptCacheState } from '../../common/state/sessionState.js'; +import { buildCanvasUri } from '../../common/canvasUri.js'; import { IAgentConfigurationService, type IAgentSessionConfigurationChangeEvent } from '../agentConfigurationService.js'; import { CopilotSessionWrapper, type ICopilotByokSessionConfig, type ICopilotModelCallFinishedEvent } from './copilotSessionWrapper.js'; import { allowCopilotSdkExecution, restoreDeferredCopilotSdkExecution } from './copilotSessionExecutionMarker.js'; @@ -489,7 +489,6 @@ export interface ICopilotAgentSessionOptions { readonly resource?: URI; readonly rawSessionId: string; readonly onDidSessionProgress: Emitter; - readonly onDidChangeCanvases: Emitter; readonly sessionLauncher: ICopilotSessionLauncher; readonly launchPlan: CopilotSessionLaunchPlan; readonly shellManager: ShellManager | undefined; @@ -550,11 +549,10 @@ export interface ICopilotAgentSessionOptions { readonly controlPlaneRpcTimeoutMs?: number; } -/** Keeps provider-owned state consistent with a live SDK working-directory mutation. */ +/** A live canvas channel and its current presentation state. */ interface ICopilotCanvasProjection { - readonly canvas: IAgentCanvas; - readonly url: string | undefined; - readonly openEventId: string; + readonly resource: URI; + readonly canvas: CanvasState; } export interface ICopilotWorkingDirectoryChangeTransaction { @@ -855,6 +853,7 @@ interface IPendingSteering { /** How long a rejected permission mode waits for managed settings to resolve before its single retry. */ const managedSettingsPermissionRetryTimeoutMs = 3000; +const MAX_PROJECTED_CANVASES = 8; /** * Encapsulates a single Copilot SDK session and all its associated bookkeeping. @@ -1289,7 +1288,6 @@ export class CopilotAgentSession extends Disposable { private readonly _pendingEditContentUris = new Map(); private readonly _onDidSessionProgress: Emitter; - private readonly _onDidChangeCanvases: Emitter; private readonly _sessionLauncher: ICopilotSessionLauncher; /** Last config materialized and pushed, so unchanged turns do no file I/O or RPC. */ private _lastAppliedShellInitScripts: string | undefined; @@ -1307,7 +1305,6 @@ export class CopilotAgentSession extends Disposable { private readonly _launchPlan: CopilotSessionLaunchPlan; private readonly _canvasByInstanceId = new Map(); private readonly _ignoredRestoredCanvasInstanceIds = new Set(); - private _canvasRevision = 0; private _canvasProjectionReady = false; private _detectInterruptedTurnOnRestore: boolean; /** Notifies the agent that this chat's turn ended. See {@link ICopilotAgentSessionOptions.onTurnEnded}. */ @@ -1441,7 +1438,6 @@ export class CopilotAgentSession extends Disposable { this._logService, ); this._onDidSessionProgress = options.onDidSessionProgress; - this._onDidChangeCanvases = options.onDidChangeCanvases; this._sessionLauncher = options.sessionLauncher; this._launchPlan = options.launchPlan; this._sandboxDiagnostics = this._register(this._instantiationService.createInstance(CopilotSandboxDiagnostics, this._ownerSessionUri.toString(), () => this._launchPlan.client.rpc.sandbox.getHostSupport())); @@ -3038,8 +3034,20 @@ export class CopilotAgentSession extends Disposable { } private _publishCanvases(): void { - const canvases = [...this._canvasByInstanceId.values()].map(value => value.canvas); - this._onDidChangeCanvases.fire({ chat: this._chatChannelUri, canvases }); + const canvases = [...this._canvasByInstanceId.values()]; + this._emitAction({ + type: ActionType.ChatCanvasesChanged, + canvases: canvases.length > 0 ? canvases.map(canvas => ({ resource: canvas.resource.toString() })) : undefined, + }); + } + + private _publishCanvasState(projection: ICopilotCanvasProjection): void { + this._onDidSessionProgress.fire({ + kind: 'canvas', + chat: this._chatChannelUri, + resource: projection.resource, + state: projection.canvas, + }); } private _clearCanvasProjection(): void { @@ -3061,20 +3069,17 @@ export class CopilotAgentSession extends Disposable { } try { const source = URI.parse(url, true); - return source.scheme === Schemas.http || source.scheme === Schemas.https ? source.toString(true) : undefined; + if ((source.scheme === Schemas.http || source.scheme === Schemas.https) && source.authority !== '') { + return source.toString(true); + } + this._logService.warn(`[Copilot:${this.sessionId}] Unsupported canvas source`); + return undefined; } catch { + this._logService.warn(`[Copilot:${this.sessionId}] Invalid canvas source URL`); return undefined; } } - resolveCanvasSource(instanceId: string, revision: number): string { - const projection = this._canvasByInstanceId.get(instanceId); - if (!projection || projection.canvas.revision !== revision || projection.canvas.availability !== AgentCanvasAvailability.Ready || projection.url === undefined) { - throw new Error(`Canvas '${instanceId}' is not available at revision ${revision}`); - } - return projection.url; - } - /** Updates the GitHub credentials used by this live SDK session. */ async updateGitHubCredentials(host: string, token: string): Promise { const result = await this._wrapper.session.rpc.gitHubAuth.setCredentials({ @@ -8427,30 +8432,41 @@ export class CopilotAgentSession extends Disposable { return; } const url = this._canvasSource(e.data.url); - const availability = url === undefined ? AgentCanvasAvailability.Unavailable : AgentCanvasAvailability.Ready; const existing = this._canvasByInstanceId.get(e.data.instanceId); - if (existing?.openEventId === e.id) { + if (existing + && existing.canvas.url === url + && existing.canvas.extensionId === e.data.extensionId + && existing.canvas.extensionName === e.data.extensionName + && existing.canvas.canvasId === e.data.canvasId + && existing.canvas.title === e.data.title + && existing.canvas.status === e.data.status) { return; } - if (!existing && this._canvasByInstanceId.size >= AGENT_HOST_CANVAS_LIMIT) { + if (!existing && this._canvasByInstanceId.size >= MAX_PROJECTED_CANVASES) { const oldestInstanceId = this._canvasByInstanceId.keys().next().value; if (oldestInstanceId !== undefined) { this._canvasByInstanceId.delete(oldestInstanceId); - this._logService.warn(`[Copilot:${this.sessionId}] Evicted oldest projected canvas after reaching the ${AGENT_HOST_CANVAS_LIMIT}-canvas limit`); + this._logService.warn(`[Copilot:${this.sessionId}] Evicted oldest projected canvas after reaching the ${MAX_PROJECTED_CANVASES}-canvas limit`); } } - const canvas: IAgentCanvas = { + const canvas: CanvasState = { instanceId: e.data.instanceId, extensionId: e.data.extensionId, ...(e.data.extensionName !== undefined ? { extensionName: e.data.extensionName } : {}), canvasId: e.data.canvasId, ...(e.data.title !== undefined ? { title: e.data.title } : {}), ...(e.data.status !== undefined ? { status: e.data.status } : {}), - revision: ++this._canvasRevision, - availability, + ...(url !== undefined ? { url } : {}), }; - this._canvasByInstanceId.set(canvas.instanceId, { canvas, url, openEventId: e.id }); - this._publishCanvases(); + const projection: ICopilotCanvasProjection = { + resource: existing?.resource ?? buildCanvasUri(this._chatChannelUri, generateUuid()), + canvas, + }; + this._canvasByInstanceId.set(canvas.instanceId, projection); + this._publishCanvasState(projection); + if (!existing) { + this._publishCanvases(); + } })); this._register(wrapper.onCanvasClosed(e => { @@ -8465,16 +8481,13 @@ export class CopilotAgentSession extends Disposable { return; } const projection = this._canvasByInstanceId.get(e.data.instanceId); - if (!projection) { + if (!projection || projection.canvas.url === undefined) { return; } - const canvas: IAgentCanvas = { - ...projection.canvas, - revision: ++this._canvasRevision, - availability: AgentCanvasAvailability.Unavailable, - }; - this._canvasByInstanceId.set(canvas.instanceId, { canvas, url: undefined, openEventId: projection.openEventId }); - this._publishCanvases(); + const { url: _url, ...canvas } = projection.canvas; + const unavailable: ICopilotCanvasProjection = { resource: projection.resource, canvas }; + this._canvasByInstanceId.set(canvas.instanceId, unavailable); + this._publishCanvasState(unavailable); })); this._register(wrapper.onSessionStart(e => { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 88b50963979080..d78e0337af869e 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -8,7 +8,6 @@ import { encodeBase64 } from '../../../base/common/buffer.js'; import { Emitter } from '../../../base/common/event.js'; import { isJsonRpcResponse } from '../../../base/common/jsonRpcProtocol.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; -import { equals } from '../../../base/common/objects.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; @@ -19,12 +18,12 @@ import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js' import { getAgentHostClientType } from '../common/agentHostClientInfo.js'; import { withSessionInitiator } from '../common/meta/agentSessionInitiatorMeta.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, readClientConnectionKind, readClientDevDeviceId, readClientMachineId, readClientTelemetryLevel, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; -import { AgentSession, type IAgentCanvasSnapshot, type IAgentCreateChatRequestOptions, type IMcpNotification } from '../common/agent.js'; +import { AgentSession, type IAgentCreateChatRequestOptions, type IMcpNotification } from '../common/agent.js'; import { isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { isAnnotationsUri } from '../common/annotationsUri.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { type IAgentService } from '../common/agentService.js'; -import { AgentHostCanvasesChangedNotification, ClaimAgentHostDetachedWorktreeExtensionMethod, collectAgentHostDebugLogsParamsValidator, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, getAgentHostExtensionInitializeResultMeta, GetAgentHostSessionStateFileExtensionMethod, ImportSessionExtensionMethod, importSessionParamsValidator, isValidAgentHostCanvasesChangedParams, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, removeSessionArtifactParamsValidator, ReportAgentHostFirstResponseExtensionMethod, ReportChatUserInteractionExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, resolveAgentHostCanvasSourceParamsValidator, ResolveAgentHostCanvasSourceExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, type IAgentHostCanvasesChangedParams, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostMcpAuthenticationRequest, type IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; +import { ClaimAgentHostDetachedWorktreeExtensionMethod, collectAgentHostDebugLogsParamsValidator, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, getAgentHostExtensionInitializeResultMeta, GetAgentHostSessionStateFileExtensionMethod, ImportSessionExtensionMethod, importSessionParamsValidator, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, removeSessionArtifactParamsValidator, ReportAgentHostFirstResponseExtensionMethod, ReportChatUserInteractionExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostMcpAuthenticationRequest, type IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { agentHostFirstResponseValidator } from '../common/otel/agentHostTiming.js'; import { chatUserInteractionAttributes, chatUserInteractionValidator } from '../../otel/common/chatUserInteraction.js'; @@ -383,8 +382,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien */ private readonly _baselineDebt = new Map>(); private readonly _replayBuffer: ActionEnvelope[] = []; - private readonly _canvasSnapshots = new Map(); - private readonly _canvasSnapshotChatsByClient = new Map>(); private readonly _telemetryReporter: AgentHostTelemetryReporter; private readonly _managedSettingsOwnerId = generateUuid(); private readonly _connectionDisposables = this._register(new DisposableMap()); @@ -441,9 +438,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien this._register(this._agentService.onMcpNotification(notification => { this._broadcastMcpNotification(notification); })); - this._register(this._agentService.onDidChangeCanvases(snapshot => { - this._recordAndBroadcastCanvasSnapshot(snapshot); - })); if (this._config.otlpLogEmitter) { this._register(this._config.otlpLogEmitter.onDidLog(record => this._broadcastOtlpLog(record))); @@ -476,7 +470,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien client = result.client; const sendInitializeResult = (response: IAgentHostExtensionInitializeResult) => { transport.send(jsonRpcSuccess(msg.id, response)); - this._sendCurrentCanvasSnapshots(result.client); }; if (result.response instanceof Promise) { this._trackRequest(result.response).then( @@ -501,7 +494,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien responsePromise.then( response => { transport.send(jsonRpcSuccess(msg.id, response)); - this._sendCurrentCanvasSnapshots(reconnectClient); }, err => transport.send(jsonRpcErrorFrom(msg.id, err)), ); @@ -727,7 +719,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien !!client.devContainers, this._otelService?.diagnosticsEnabled, !!this._agentService.importSession, - this._supportsCanvases(client), ), snapshots, defaultDirectory: this._config.defaultDirectory, @@ -1317,7 +1308,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } else { this._clients.delete(client.clientId); this._baselineDebt.delete(client.clientId); - this._canvasSnapshotChatsByClient.delete(client.clientId); } } } @@ -1405,7 +1395,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien isLocalClient(clientId: string): boolean { const record = this._clients.get(clientId); return record?.state === 'active' - && record.connections.some(connection => connection.telemetryConnectionActive && this._supportsCanvases(connection)); + && record.connections.some(connection => connection.telemetryConnectionActive && this._isLocalClientConnection(connection)); } getConnectedClientTransportCounts(): ReadonlyMap { @@ -1466,7 +1456,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return count; } - private _supportsCanvases(client: IConnectedClient): boolean { + private _isLocalClientConnection(client: IConnectedClient): boolean { return client.telemetryContext.connectionKind === AgentHostClientConnectionKind.Local && client.telemetryContext.transportKind === AgentHostTransportKind.MessagePort; } @@ -1530,7 +1520,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien record.disconnectTimeouts.dispose(); this._clients.delete(clientId); this._baselineDebt.delete(clientId); - this._canvasSnapshotChatsByClient.delete(clientId); } } } @@ -2017,34 +2006,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return session; } - private _handleResolveCanvasSourceRequest(client: IConnectedClient, params: unknown): Promise | undefined { - if (!this._supportsCanvases(client)) { - return undefined; - } - const validated = resolveAgentHostCanvasSourceParamsValidator.validate(params); - if (validated.error) { - return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, validated.error.message)); - } - const { chat: chatParam, instanceId, revision } = validated.content; - if (!chatParam.trim() || !instanceId.trim() || !Number.isSafeInteger(revision) || revision <= 0) { - return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'chat and instanceId must be non-empty and revision must be a positive safe integer')); - } - let chat: URI; - try { - chat = URI.parse(chatParam, true); - } catch { - return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'chat must be a valid URI string')); - } - if (!parseChatUri(chat)) { - return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'chat must be an Agent Host chat URI')); - } - return this._resolveCanvasSource(chat, instanceId, revision); - } - - private async _resolveCanvasSource(chat: URI, instanceId: string, revision: number): Promise<{ url: string }> { - return { url: await this._agentService.resolveCanvasSource(chat, instanceId, revision) }; - } - /** * Handle VS Code extension methods that are not yet part of the typed * protocol. Returns a Promise if the method was recognized, undefined @@ -2087,9 +2048,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien if (method === RemoveSessionArtifactExtensionMethod) { return this._handleRemoveSessionArtifactRequest(params); } - if (method === ResolveAgentHostCanvasSourceExtensionMethod) { - return this._handleResolveCanvasSourceRequest(client, params); - } if (this._config.allowExtensionMethods === false) { return undefined; } @@ -2341,72 +2299,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } } - private _recordAndBroadcastCanvasSnapshot(snapshot: IAgentCanvasSnapshot): void { - const params: IAgentHostCanvasesChangedParams = { - chat: snapshot.chat.toString(), - canvases: [...snapshot.canvases], - }; - if (!isValidAgentHostCanvasesChangedParams(params)) { - this._logService.error(`[ProtocolServer] Ignoring invalid canvas snapshot for ${params.chat}`); - return; - } - if (!parseChatUri(snapshot.chat)) { - this._logService.error(`[ProtocolServer] Ignoring canvas snapshot for non-chat URI ${params.chat}`); - return; - } - const previous = this._canvasSnapshots.get(params.chat); - if (previous && equals(previous.canvases, snapshot.canvases)) { - return; - } - if (snapshot.canvases.length > 0) { - this._canvasSnapshots.set(params.chat, snapshot); - } else { - if (!previous) { - return; - } - this._canvasSnapshots.delete(params.chat); - } - for (const record of this._clients.values()) { - const client = this._getActiveClientFromRecord(record); - if (client && this._supportsCanvases(client)) { - this._sendCanvasSnapshot(client, params); - } - } - } - - private _sendCurrentCanvasSnapshots(client: IConnectedClient): void { - if (!this._supportsCanvases(client)) { - return; - } - const staleChats = new Set(this._canvasSnapshotChatsByClient.get(client.clientId)); - for (const snapshot of this._canvasSnapshots.values()) { - staleChats.delete(snapshot.chat.toString()); - this._sendCanvasSnapshot(client, { - chat: snapshot.chat.toString(), - canvases: [...snapshot.canvases], - }); - } - for (const chat of staleChats) { - this._sendCanvasSnapshot(client, { chat, canvases: [] }); - } - } - - private _sendCanvasSnapshot(client: IConnectedClient, params: IAgentHostCanvasesChangedParams): void { - let chats = this._canvasSnapshotChatsByClient.get(client.clientId); - if (!chats) { - chats = new Set(); - this._canvasSnapshotChatsByClient.set(client.clientId, chats); - } - if (params.canvases.length > 0) { - chats.add(params.chat); - } else { - chats.delete(params.chat); - } - // eslint-disable-next-line local/code-no-dangerous-type-assertions - const message = { jsonrpc: '2.0' as const, method: AgentHostCanvasesChangedNotification, params } as unknown as AhpServerNotification; - client.transport.send(message); - } - private _broadcastNotification(notification: INotification): void { // Each protocol notification now ships as its own top-level method. The // `type` discriminant on our local {@link ProtocolNotification} union is @@ -2559,8 +2451,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } this._pendingReverseRequests.clear(); this._replayBuffer.length = 0; - this._canvasSnapshots.clear(); - this._canvasSnapshotChatsByClient.clear(); super.dispose(); } } diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index 46b22e32230e3b..ea4e1d901f7ab4 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -13,12 +13,15 @@ import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { ActionType, type ActionEnvelope, type ChatTurnStartedAction, type ClientChangesetAction } from '../../common/state/sessionActions.js'; import { AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, ChangesetStatus, ChatInteractivity, MessageKind, ResponsePartKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TerminalLifecycleStatus, TurnState, type AnnotationsState, type AutomationRunState, type AutomationState, type ChangesetState, type ErrorInfo, type RootState, type SessionState, type SessionSummary, type TerminalState, type Turn } from '../../common/state/protocol/state.js'; import { AUTOMATION_CATALOG_URI, buildChatUri, buildDefaultChatUri, createChatState, createDefaultChatSummary, getTurnError, ROOT_STATE_URI, StateComponents, type ChatState } from '../../common/state/sessionState.js'; -import { AgentSubscriptionManager, AutomationCatalogSubscription, AutomationRunSubscription, ChangesetStateSubscription, ChatStateSubscription, isActionEnvelopeRelevantToSubscriptionUris, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; +import { AgentSubscriptionManager, AutomationCatalogSubscription, AutomationRunSubscription, CanvasStateSubscription, ChangesetStateSubscription, ChatStateSubscription, isActionEnvelopeRelevantToSubscriptionUris, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; +import type { CanvasState } from '../../common/state/protocol/channels-canvas/state.js'; import { normalizeLegacyActionEnvelope, readLegacyTurnError } from '../../common/state/legacyProtocolCompatibility.js'; import { chatReducer } from '../../common/state/sessionReducers.js'; import { resolveAgentHostSession } from '../../common/agentHostSubscriptionService.js'; import { buildFolderChangesetOwnerUri } from '../../common/changesetUri.js'; import { buildNonPtyShellTerminalUri } from '../../common/nonPtyShellTerminalUri.js'; +import { buildCanvasUri, parseCanvasChatUri } from '../../common/canvasUri.js'; +import type { IStateSnapshot } from '../../common/state/sessionProtocol.js'; // Helpers @@ -110,6 +113,21 @@ suite('resolveAgentHostSession', () => { terminal: sessionUri, }); }); + + test('pins local canvases to their owning session without interpreting other hosts', () => { + const chat = URI.parse(buildChatUri(sessionUri, 'peer')); + const canvas = buildCanvasUri(chat, 'preview'); + const remote = canvas.with({ authority: 'another-host' }); + assert.deepStrictEqual({ + owner: parseCanvasChatUri(canvas)?.toString(), + session: resolveAgentHostSession(canvas).toString(), + remoteOwner: parseCanvasChatUri(remote), + extraPath: parseCanvasChatUri(canvas.with({ path: canvas.path + '/extra' })), + query: parseCanvasChatUri(canvas.with({ query: 'source=other' })), + }, { + owner: chat.toString(), session: sessionUri, remoteOwner: undefined, extraPath: undefined, query: undefined, + }); + }); }); function makeAutomationCatalogState(): AutomationState { @@ -266,6 +284,25 @@ suite('ChangesetStateSubscription', () => { }); +suite('CanvasStateSubscription', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('buffers new canvas source state across initial and reconnect snapshots', () => { + const resource = 'ahp-canvas://another-host/documents/preview'; + const subscription = disposables.add(new CanvasStateSubscription(resource, 'c1', noop)); + const initial: CanvasState = { instanceId: 'preview', extensionId: 'example:provider', canvasId: 'preview', url: 'https://example.test/old' }; + const replacement: CanvasState = { ...initial, url: 'https://example.test/new' }; + subscription.receiveEnvelope(makeEnvelope({ type: ActionType.CanvasStateChanged, canvas: replacement }, 2, undefined, undefined, resource)); + subscription.handleSnapshot(initial, 1); + const ready = subscription.value; + subscription.beginSnapshotRefresh(); + const { url: _url, ...unavailable } = replacement; + subscription.receiveEnvelope(makeEnvelope({ type: ActionType.CanvasStateChanged, canvas: unavailable }, 4, undefined, undefined, resource)); + subscription.handleSnapshot(replacement, 3); + assert.deepStrictEqual({ ready, unavailable: subscription.value }, { ready: replacement, unavailable }); + }); +}); + // RootStateSubscription suite('RootStateSubscription', () => { @@ -1026,7 +1063,7 @@ suite('AgentSubscriptionManager', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createManager(subscribe: (resource: URI) => Promise<{ resource: string; state: SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState | AutomationState; fromSeq: number }> = async resource => { + function createManager(subscribe: (resource: URI) => Promise = async resource => { const key = resource.toString(); subscribedResources.push(key); if (key.endsWith('/annotations')) { @@ -1309,6 +1346,24 @@ suite('AgentSubscriptionManager', () => { ref.dispose(); }); + test('shares and releases the exact advertised canvas subscription', async () => { + const resource = URI.parse('ahp-canvas://another-host/documents/preview'); + const canvas: CanvasState = { instanceId: 'preview', extensionId: 'example:provider', canvasId: 'preview' }; + const mgr = createManager(async uri => { + subscribedResources.push(uri.toString()); + return { resource: uri.toString(), state: canvas, fromSeq: 0 }; + }); + const first = mgr.getSubscription(StateComponents.Canvas, resource, 'first'); + const second = mgr.getSubscription(StateComponents.Canvas, resource, 'second'); + await Event.toPromise(first.object.onDidChange); + first.dispose(); + const retained = mgr.currentSubscriptionUris().map(uri => uri.toString()); + second.dispose(); + assert.deepStrictEqual({ subscribedResources, retained, unsubscribedResources }, { + subscribedResources: [resource.toString()], retained: [resource.toString()], unsubscribedResources: [resource.toString()], + }); + }); + test('uses the normalized automation catalogue URI internally', async () => { const normalizedCatalogUri = URI.parse(AUTOMATION_CATALOG_URI).toString(); const mgr = createManager(async resource => { diff --git a/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts b/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts index e77ec770cee901..83e5eeb6849fe2 100644 --- a/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts +++ b/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts @@ -12,7 +12,6 @@ import { IFileWriteOptions } from '../../../files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AhpJsonlLogger, getAhpLogByteLength, isAhpLogFileFor, stringifyAhpLogEntry } from '../../common/ahpJsonlLogger.js'; -import { ResolveAgentHostCanvasSourceExtensionMethod } from '../../common/agentHostExtensionProtocol.js'; suite('AhpJsonlLogger', () => { @@ -187,7 +186,7 @@ suite('AhpJsonlLogger', () => { }); }); - test('redacts resolved canvas source URLs by request id', async () => { + test('redacts canvas URLs in actions and snapshots without mutating live state', async () => { const fileService = store.add(new FileService(new NullLogService())); store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); const logger = store.add(new AhpJsonlLogger( @@ -196,20 +195,20 @@ suite('AhpJsonlLogger', () => { new NullLogService(), )); - logger.log({ jsonrpc: '2.0', id: 7, method: ResolveAgentHostCanvasSourceExtensionMethod, params: { chat: 'ahp-chat:/session/main', instanceId: 'preview', revision: 1 } }, 'c2s'); - logger.log({ jsonrpc: '2.0', id: 8, result: { url: 'https://visible.example/path' } }, 's2c'); - logger.log({ jsonrpc: '2.0', id: 7, result: { url: 'https://secret.example/path?token=sensitive' } }, 's2c'); + const canvas = { instanceId: 'preview', extensionId: 'project:preview', canvasId: 'preview', url: 'https://secret.example/path?token=sensitive' }; + const action = { type: 'canvas/stateChanged', canvas }; + logger.log({ jsonrpc: '2.0', method: 'action', params: { channel: 'ahp-canvas:/preview', action } }, 's2c'); + logger.log({ jsonrpc: '2.0', id: 7, result: { snapshot: { resource: 'ahp-canvas:/preview', state: canvas, fromSeq: 1 } } }, 's2c'); + logger.log({ jsonrpc: '2.0', id: 8, result: { snapshots: [{ resource: URI.parse('ahp-canvas:/preview'), state: canvas, fromSeq: 1 }] } }, 's2c'); await logger.flush(); - const entries = (await fileService.readFile(logger.resource)).value.toString().split('\n').filter(Boolean).map(line => JSON.parse(line)); - assert.deepStrictEqual(entries.map(entry => entry.result?.url), [ - undefined, - 'https://visible.example/path', - '', - ]); + assert.deepStrictEqual({ + urls: [entries[0].params.action.canvas.url, entries[1].result.snapshot.state.url, entries[2].result.snapshots[0].state.url], + liveSource: canvas.url, + }, { urls: Array(3).fill(''), liveSource: 'https://secret.example/path?token=sensitive' }); }); - test('fails closed when canvas source request tracking saturates', async () => { + test('redacts replayed canvas actions and incomplete canvas state fail closed', async () => { const fileService = store.add(new FileService(new NullLogService())); store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); const logger = store.add(new AhpJsonlLogger( @@ -218,20 +217,23 @@ suite('AhpJsonlLogger', () => { new NullLogService(), )); - for (let id = 0; id < 1025; id++) { - logger.log({ jsonrpc: '2.0', id, method: ResolveAgentHostCanvasSourceExtensionMethod, params: { chat: 'ahp-chat:/session/main', instanceId: `preview-${id}`, revision: 1 } }, 'c2s'); - } - logger.log({ jsonrpc: '2.0', id: 0, result: { url: 'https://secret.example/oldest' } }, 's2c'); - logger.log({ jsonrpc: '2.0', id: 1024, result: { url: 'https://secret.example/overflow' } }, 's2c'); - logger.log({ jsonrpc: '2.0', id: 'unrelated', result: { url: 'https://example.com/fail-closed' } }, 's2c'); + logger.log({ + jsonrpc: '2.0', id: 1, result: { + type: 'replay', actions: [{ + channel: 'ahp-canvas:/preview', action: { type: 'canvas/stateChanged', canvas: { url: 'https://secret.example/replay' } }, + }] + } + }, 's2c'); + logger.log({ jsonrpc: '2.0', id: 2, result: { snapshot: { resource: 'ahp-canvas:/preview', state: { url: 'https://secret.example/snapshot' } } } }, 's2c'); + logger.log({ jsonrpc: '2.0', id: 3, result: { url: 'https://example.test/unrelated' } }, 's2c'); await logger.flush(); const entries = (await fileService.readFile(logger.resource)).value.toString().split('\n').filter(Boolean).map(line => JSON.parse(line)); - assert.deepStrictEqual(entries.slice(-3).map(entry => entry.result.url), [ - '', - '', - '', - ]); + assert.deepStrictEqual([ + entries[0].result.actions[0].action.canvas.url, + entries[1].result.snapshot.state.url, + entries[2].result.url, + ], ['', '', 'https://example.test/unrelated']); }); test('flush waits for batched writes and ordering is preserved across drains', async () => { diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 8c94eeca13dad6..6e0f4b1628db86 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -18,9 +18,9 @@ import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentHostClientState, AgentHostProtocolClient, type IAgentHostProtocolClientOptions } from '../../browser/agentHostProtocolClient.js'; -import { AgentHostCanvasesChangedNotification, DevContainerConnectExtensionMethod, DevContainerIsDockerAvailableExtensionMethod, DevContainerOutputNotification, DevContainerRelayMessageNotification, DevContainerRelaySendExtensionMethod, DevContainerRemoveExtensionMethod, DevContainerStopExtensionMethod, getAgentHostExtensionInitializeResultMeta, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, ResolveAgentHostCanvasSourceExtensionMethod, type IAgentHostMcpAuthenticationRequest } from '../../common/agentHostExtensionProtocol.js'; +import { DevContainerConnectExtensionMethod, DevContainerIsDockerAvailableExtensionMethod, DevContainerOutputNotification, DevContainerRelayMessageNotification, DevContainerRelaySendExtensionMethod, DevContainerRemoveExtensionMethod, DevContainerStopExtensionMethod, getAgentHostExtensionInitializeResultMeta, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, type IAgentHostMcpAuthenticationRequest } from '../../common/agentHostExtensionProtocol.js'; import { McpAuthRequiredReason } from '../../common/state/protocol/channels-session/state.js'; -import { AgentCanvasAvailability, AgentSession, AuthenticateParams } from '../../common/agent.js'; +import { AuthenticateParams } from '../../common/agent.js'; import { authenticationAccountMeta } from '../../common/meta/agentAuthenticationAccount.js'; import { agentHostAuthority, toAgentHostUri } from '../../common/agentHostUri.js'; import { AgentHostFileSystemProvider } from '../../common/agentHostFileSystemProvider.js'; @@ -32,13 +32,13 @@ import { ContentEncoding, ReconnectResultType } from '../../common/state/protoco import { ChatSourceKind } from '../../common/state/protocol/channels-chat/commands.js'; import { ChatInteractivity, ResourceChangeType } from '../../common/state/protocol/state.js'; import { AhpErrorCodes, JsonRpcErrorCodes } from '../../common/state/protocol/errors.js'; -import { PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '../../common/state/protocol/version/registry.js'; +import { isActionKnownToVersion, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '../../common/state/protocol/version/registry.js'; import { ActionType, type ActionEnvelope, type ChatTurnCompleteAction, type ChatTurnStartedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionTitleChangedAction } from '../../common/state/sessionActions.js'; import { chatReducer } from '../../common/state/sessionReducers.js'; import { ProtocolError, type AhpServerNotification, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../../common/state/sessionProtocol.js'; import { hasKey } from '../../../../base/common/types.js'; import { mainWindow } from '../../../../base/browser/window.js'; -import { AUTOMATION_CATALOG_URI, buildChatUri, buildDefaultChatUri, createChatState, createSessionState, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, TurnState, customizationId, withSessionExternal, withSessionWorkspaceless, type ChatState, type SessionState } from '../../common/state/sessionState.js'; +import { AUTOMATION_CATALOG_URI, buildChatUri, createChatState, createSessionState, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, TurnState, customizationId, withSessionExternal, withSessionWorkspaceless, type ChatState, type SessionState } from '../../common/state/sessionState.js'; import { AgentHostTransportFailureReason, NonReconnectableTransportError, type IClientTransport, type IProtocolTransport, type ITransportCloseDetails } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { ITelemetryService, TelemetryConfiguration, TelemetryLevel, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js'; @@ -440,20 +440,6 @@ suite('AgentHostProtocolClient', () => { assert.deepStrictEqual(supported, [false, false, false, true]); }); - test('canvas facade requires the versioned capability and a local host identity', async () => { - const supported: boolean[] = []; - for (const meta of [undefined, { 'vscode.canvases.v1': 1 }, { 'vscode.canvases.v1': false }, getAgentHostExtensionInitializeResultMeta(true, false, false, false, true)]) { - const { client, transport } = createClientForIdentity(LOCAL_AGENT_HOST_RESOURCE_IDENTITY); - await connectClient(client, transport, meta); - supported.push(client.canvases !== undefined); - } - const remote = createClientForIdentity('remote.example:1234'); - await connectClient(remote.client, remote.transport, getAgentHostExtensionInitializeResultMeta(true, false, false, false, true)); - supported.push(remote.client.canvases !== undefined); - - assert.deepStrictEqual(supported, [false, false, false, true, false]); - }); - test('Dev Container facade uses the parent transport and validates notifications', async () => { const { client, transport } = createClient(); await connectClient(client, transport, getAgentHostExtensionInitializeResultMeta(true, true)); @@ -753,6 +739,17 @@ suite('AgentHostProtocolClient', () => { }]); }); + test('chat read-state actions require AHP 0.10', () => { + const action = { type: ActionType.ChatIsReadChanged, isRead: true } as const; + assert.deepStrictEqual({ + v09: isActionKnownToVersion(action, '0.9.0'), + v10: isActionKnownToVersion(action, '0.10.0'), + }, { + v09: false, + v10: true, + }); + }); + test('listSessions derives the compatibility directory from the primary root', async () => { const { client, transport } = createClient(); const directories = [URI.file('/workspace/primary'), URI.file('/workspace/secondary')]; @@ -1133,64 +1130,29 @@ suite('AgentHostProtocolClient', () => { }); }); - test('applies full canvas snapshots, suppresses duplicates, clears, and resolves an exact revision', async () => { - const { client, transport } = createClientForIdentity(LOCAL_AGENT_HOST_RESOURCE_IDENTITY); - await connectClient(client, transport, getAgentHostExtensionInitializeResultMeta(true, false, false, false, true)); - transport.sentMessages.length = 0; - const canvases = client.canvases; - assert.ok(canvases); - const chat = URI.parse(buildDefaultChatUri(AgentSession.uri('copilotcli', 'test'))); - const snapshots: unknown[] = []; - disposables.add(canvases.onDidChange(snapshot => snapshots.push({ - chat: snapshot.chat.toString(), - canvases: snapshot.canvases, - }))); - const params = { - chat: chat.toString(), - canvases: [{ - instanceId: 'preview-1', - extensionId: 'project:preview', - canvasId: 'preview', - revision: 3, - availability: AgentCanvasAvailability.Ready, - }], - }; - transport.fireMessage({ jsonrpc: '2.0', method: AgentHostCanvasesChangedNotification, params } as unknown as ProtocolMessage); - transport.fireMessage({ jsonrpc: '2.0', method: AgentHostCanvasesChangedNotification, params } as unknown as ProtocolMessage); - - const resultPromise = canvases.resolveSource(chat, 'preview-1', 3); - const sourceRequest = transport.sentMessages.at(-1) as JsonRpcRequest; - - assert.deepStrictEqual(sourceRequest, { - jsonrpc: '2.0', - id: sourceRequest.id, - method: ResolveAgentHostCanvasSourceExtensionMethod, - params: { - chat: chat.toString(), - instanceId: 'preview-1', - revision: 3, + test('subscribes to an opaque host-advertised canvas channel', async () => { + const { client, transport } = createClient(); + const resource = URI.parse('ahp-canvas://other-host/document/preview'); + const reference = disposables.add(client.getSubscription(StateComponents.Canvas, resource, 'canvas-test')); + await timeout(0); + const request = transport.sentMessages.find((message): message is JsonRpcRequest => + hasKey(message, { method: true, id: true }) && message.method === 'subscribe'); + assert.ok(request); + transport.fireMessage({ + jsonrpc: '2.0', id: request.id, + result: { + snapshot: { + resource: resource.toString(), state: { + instanceId: 'preview', extensionId: 'example:provider', canvasId: 'preview', + url: 'https://example.test/preview', + }, fromSeq: 1 + } }, }); - - transport.fireMessage({ jsonrpc: '2.0', id: sourceRequest.id, result: { url: 'https://example.test/preview' } }); - const source = await resultPromise; - transport.fireMessage({ - jsonrpc: '2.0', - method: AgentHostCanvasesChangedNotification, - params: { chat: chat.toString(), canvases: [] }, - } as unknown as ProtocolMessage); - - assert.deepStrictEqual({ - source, - snapshots, - current: canvases.getSnapshots(), - }, { - source: 'https://example.test/preview', - snapshots: [ - params, - { chat: chat.toString(), canvases: [] }, - ], - current: [], + await timeout(0); + assert.deepStrictEqual({ params: request.params, state: reference.object.value }, { + params: { channel: resource.toString() }, + state: { instanceId: 'preview', extensionId: 'example:provider', canvasId: 'preview', url: 'https://example.test/preview' }, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 33c9d4f102839d..4fb49ffe86137f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, ChatInputQuestionKind, ChatInputResponseKind, ChatInteractivity, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, createErrorResponsePart, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; +import { ChangesetStatus, ChatInputQuestionKind, ChatInputResponseKind, ChatInteractivity, ChatOriginKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, createErrorResponsePart, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { BackgroundWorkKind, type BackgroundShellWork } from '../../common/state/protocol/channels-chat/state.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -23,6 +23,8 @@ import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../comm import { readAgentHostResources } from '../../common/meta/agentHostResources.js'; import { supportsRemoteSessions } from '../../common/meta/agentRemoteSessionMeta.js'; import { collectAgentHostResources } from '../../node/agentHostResources.js'; +import { buildCanvasUri } from '../../common/canvasUri.js'; +import type { CanvasState } from '../../common/state/protocol/channels-canvas/state.js'; suite('AgentHostStateManager', () => { @@ -127,6 +129,42 @@ suite('AgentHostStateManager', () => { }); }); + test('hosts canvas state independently of chat membership and removes dead sources', () => { + manager.createSession(makeSessionSummary()); + const resource = buildCanvasUri(URI.parse(sessionChatUri), 'preview').toString(); + const canvas: CanvasState = { + instanceId: 'preview', extensionId: 'project:preview', canvasId: 'preview', + url: 'https://example.test/preview', + }; + const actions: ActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(action => actions.push(action))); + manager.setCanvasState(sessionChatUri, resource, canvas); + manager.dispatchServerAction(sessionChatUri, { + type: ActionType.ChatCanvasesChanged, canvases: [{ resource }], + }); + const ready = manager.getSnapshot(resource)?.state; + const references = manager.getChatState(sessionChatUri)?.canvases; + const { url: _url, ...unavailable } = canvas; + manager.setCanvasState(sessionChatUri, resource, unavailable); + const cleared = manager.getSnapshot(resource)?.state; + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatCanvasesChanged, canvases: undefined }); + assert.deepStrictEqual({ + ready, cleared, references, removed: manager.getSnapshot(resource), + types: actions.map(action => action.action.type), + }, { + ready: canvas, cleared: unavailable, references: [{ resource }], removed: undefined, + types: [ActionType.CanvasStateChanged, ActionType.ChatCanvasesChanged, ActionType.CanvasStateChanged, ActionType.ChatCanvasesChanged], + }); + }); + + test('session eviction removes owned live canvas channels', () => { + manager.createSession(makeSessionSummary()); + const resource = buildCanvasUri(URI.parse(sessionChatUri), 'preview').toString(); + manager.setCanvasState(sessionChatUri, resource, { instanceId: 'preview', extensionId: 'project:preview', canvasId: 'preview' }); + manager.removeSession(sessionUri); + assert.strictEqual(manager.getSnapshot(resource), undefined); + }); + test('getSnapshot returns root snapshot', () => { const snapshot = manager.getSnapshot(ROOT_STATE_URI); assert.ok(snapshot); @@ -1515,16 +1553,25 @@ suite('AgentHostStateManager', () => { manager.dispatchClientAction(peerChat, { type: ActionType.ChatIsArchivedChanged, isArchived: true }, { clientId: 'client', clientSeq: 1 }); const peerSummary = manager.getSessionState(sessionUri)?.chats.find(chat => chat.resource === peerChat); + const catalogSummary = manager.getSessionSummary(sessionUri)?.chats?.find(chat => chat.resource === peerChat); const chatUpdated = envelopes.find(envelope => envelope.action.type === ActionType.SessionChatUpdated); assert.deepStrictEqual({ peerArchived: !!peerSummary && (peerSummary.status & SessionStatus.IsArchived) !== 0, peerStateArchived: ((manager.getChatState(peerChat)?.status ?? 0) & SessionStatus.IsArchived) !== 0, + catalogSummary, sessionArchived: ((manager.getSessionSummary(sessionUri)?.status ?? 0) & SessionStatus.IsArchived) !== 0, action: chatUpdated?.action, }, { peerArchived: true, peerStateArchived: true, + catalogSummary: { + resource: peerChat, + title: 'Peer', + origin: { kind: ChatOriginKind.User }, + status: SessionStatus.Idle | SessionStatus.IsArchived, + archived: true, + }, sessionArchived: false, action: { type: ActionType.SessionChatUpdated, @@ -2137,7 +2184,7 @@ suite('AgentHostStateManager', () => { ...makeSessionSummary(), chats: [ { resource: defaultChat, title: '' }, - { resource: peerChat, title: 'Peer', archived: false }, + { resource: peerChat, title: 'Peer', status: SessionStatus.Idle }, ], defaultChat, }; diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 1a6f6dc25ea41f..ac7a3ddbf4c239 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -38,6 +38,8 @@ import { IConnectionTrackerService } from '../../common/agentService.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostAutoArchiveMergedSessionsAfterDaysConfigKey, AgentHostAutoDeleteArchivedMergedSessionsAfterDaysConfigKey, AgentHostArtifactToolsConfigKey, AgentHostAutoAttachPullRequestsConfigKey, AgentHostSessionCatalogEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey } from '../../common/agentHostSchema.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; +import { buildCanvasUri } from '../../common/canvasUri.js'; +import type { CanvasState } from '../../common/state/protocol/channels-canvas/state.js'; import { ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js'; import { CodexSessionConfigKey } from '../../common/codexSessionConfigKeys.js'; import { ISessionCatalogSyncPendingSnapshot, ISessionCatalogSyncSnapshot, ISessionDatabase, ISessionDataService, SessionCatalogSyncWriteResult } from '../../common/sessionDataService.js'; @@ -1244,6 +1246,25 @@ suite('AgentService (node dispatcher)', () => { teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); + test('canvas subscriptions only read live state and never restore a provider', async () => { + registerTestAgentProvider(service, copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const chat = URI.parse(buildDefaultChatUri(session.toString())); + const resource = buildCanvasUri(chat, 'preview'); + const canvas: CanvasState = { instanceId: 'preview', extensionId: 'example:provider', canvasId: 'preview', url: 'https://example.test/preview' }; + const stateManager = getStateManager(service); + stateManager.setCanvasState(chat.toString(), resource.toString(), canvas); + service.restoreSession = async () => { throw new Error('Canvas subscriptions must not restore a provider'); }; + copilotAgent.chats.prepareChat = async () => { throw new Error('Canvas subscriptions must not prepare a chat'); }; + const snapshot = await service.subscribe(resource, 'canvas-viewer'); + await assert.rejects(service.subscribe(resource, 'cancelled-viewer', () => false), /Subscription cancelled/); + stateManager.removeSession(session.toString()); + await assert.rejects(service.subscribe(resource, 'missing-viewer'), + error => error instanceof ProtocolError && error.code === AhpErrorCodes.NotFound); + service.unsubscribe(resource, 'canvas-viewer'); + assert.deepStrictEqual(snapshot.state, canvas); + }); + test('subscribing checks writer ownership and Retry refreshes shared input state without a turn', async () => { registerTestAgentProvider(service, copilotAgent); const session = await service.createSession({ provider: 'copilot' }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 7fa460940c5d5f..c23d2fe8d60f43 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -32,7 +32,7 @@ import type { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; -import { AgentSession, SubagentChatSignal, type AgentSignal, type IAgentActionSignal, type IAgentCanvasSnapshot, type IAgentTelemetryContext, type IAgentToolPendingConfirmationSignal } from '../../common/agent.js'; +import { AgentSession, SubagentChatSignal, type AgentSignal, type IAgentActionSignal, type IAgentTelemetryContext, type IAgentToolPendingConfirmationSignal } from '../../common/agent.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, createUnknownAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanReview.js'; @@ -1090,7 +1090,6 @@ async function createAgentSession(disposables: DisposableStore, options?: { runtime: TestCopilotSessionRuntime; mockSession: MockCopilotSession; signals: AgentSignal[]; - canvasSnapshots: IAgentCanvasSnapshot[]; waitForSignal: (predicate: (signal: AgentSignal) => boolean) => Promise; terminalManager: TestAgentHostTerminalManager; storedFileContents: ReadonlyMap; @@ -1106,9 +1105,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { storageService: IAgentHostStorageService; }> { const progressEmitter = disposables.add(new Emitter()); - const canvasEmitter = disposables.add(new Emitter()); const signals: AgentSignal[] = []; - const canvasSnapshots: IAgentCanvasSnapshot[] = []; const waiters: { predicate: (signal: AgentSignal) => boolean; deferred: DeferredPromise }[] = []; disposables.add(progressEmitter.event(signal => { @@ -1121,7 +1118,6 @@ async function createAgentSession(disposables: DisposableStore, options?: { } } })); - disposables.add(canvasEmitter.event(snapshot => canvasSnapshots.push(snapshot))); const waitForSignal = (predicate: (signal: AgentSignal) => boolean): Promise => { const existing = signals.find(predicate); @@ -1377,7 +1373,6 @@ async function createAgentSession(disposables: DisposableStore, options?: { chatChannelUri, rawSessionId: 'test-session-1', onDidSessionProgress: progressEmitter, - onDidChangeCanvases: canvasEmitter, sessionLauncher, launchPlan, shellManager: options?.shellManager, @@ -1421,7 +1416,6 @@ async function createAgentSession(disposables: DisposableStore, options?: { runtime, mockSession, signals, - canvasSnapshots, waitForSignal, terminalManager, storedFileContents, @@ -2447,8 +2441,8 @@ suite('CopilotAgentSession', () => { }); }); - test('projects only live canvas events after resume and fences source revisions', async () => { - const { session, mockSession, canvasSnapshots } = await createAgentSession(disposables, { + test('projects live canvas channels after resume and clears unavailable sources', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables, { resume: true, configureMockSession: mock => { mock.openCanvases.push({ @@ -2484,61 +2478,43 @@ suite('CopilotAgentSession', () => { title: 'Preview', status: 'ready', url: 'https://example.test/live', - }, { id: 'live-open' }); - const source = session.resolveCanvasSource('preview', 1); + }); mockSession.fire('session.canvas.unavailable', { instanceId: 'preview', extensionId: 'project:preview', canvasId: 'preview', }); - assert.throws(() => session.resolveCanvasSource('preview', 1), /not available/); session.dispose(); - + const canvases = signals.filter(signal => signal.kind === 'canvas'); + const resource = canvases[0]?.resource.toString(); assert.deepStrictEqual({ - source, - snapshots: canvasSnapshots.map(snapshot => ({ - chat: snapshot.chat.toString(), - canvases: snapshot.canvases, - })), + states: canvases.map(signal => signal.state), + sameResource: canvases[1]?.resource.toString() === resource, + actions: getActions(signals).filter(action => action.type === ActionType.ChatCanvasesChanged), }, { - source: 'https://example.test/live', - snapshots: [ - { - chat: buildDefaultChatUri(AgentSession.uri('copilot', 'test-session-1')), - canvases: [{ - instanceId: 'preview', - extensionId: 'project:preview', - extensionName: 'Preview', - canvasId: 'preview', - title: 'Preview', - status: 'ready', - revision: 1, - availability: 'ready', - }], - }, + states: [{ + instanceId: 'preview', extensionId: 'project:preview', extensionName: 'Preview', + canvasId: 'preview', title: 'Preview', status: 'ready', url: 'https://example.test/live', + }, { + instanceId: 'preview', extensionId: 'project:preview', extensionName: 'Preview', + canvasId: 'preview', title: 'Preview', status: 'ready', + }], + sameResource: true, + actions: [ { - chat: buildDefaultChatUri(AgentSession.uri('copilot', 'test-session-1')), - canvases: [{ - instanceId: 'preview', - extensionId: 'project:preview', - extensionName: 'Preview', - canvasId: 'preview', - title: 'Preview', - status: 'ready', - revision: 2, - availability: 'unavailable', - }], + type: ActionType.ChatCanvasesChanged, + canvases: [{ resource }], }, { - chat: buildDefaultChatUri(AgentSession.uri('copilot', 'test-session-1')), - canvases: [], + type: ActionType.ChatCanvasesChanged, + canvases: undefined, }, ], }); }); test('ignores canvas events when the runtime was launched with canvases disabled', async () => { - const { session, mockSession, canvasSnapshots } = await createAgentSession(disposables, { canvasRuntimeEnabled: false }); + const { session, mockSession, signals } = await createAgentSession(disposables, { canvasRuntimeEnabled: false }); mockSession.fire('session.canvas.opened', { instanceId: 'preview', @@ -2547,13 +2523,15 @@ suite('CopilotAgentSession', () => { url: 'https://example.test/live', }); - assert.deepStrictEqual(canvasSnapshots, []); - assert.throws(() => session.resolveCanvasSource('preview', 1), /not available/); + assert.deepStrictEqual({ + states: signals.filter(signal => signal.kind === 'canvas'), + membership: getActions(signals).filter(action => action.type === ActionType.ChatCanvasesChanged), + }, { states: [], membership: [] }); session.dispose(); }); - test('publishes a new revision when the agent reopens an unchanged canvas instance', async () => { - const { session, mockSession, canvasSnapshots } = await createAgentSession(disposables); + test('keeps a canvas lifetime for updates and creates a new channel after closing', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); const canvas = { instanceId: 'preview', extensionId: 'project:preview', @@ -2564,20 +2542,24 @@ suite('CopilotAgentSession', () => { mockSession.fire('session.canvas.opened', canvas, { id: 'open-1' }); mockSession.fire('session.canvas.opened', canvas, { id: 'open-2' }); + mockSession.fire('session.canvas.closed', { instanceId: canvas.instanceId, extensionId: canvas.extensionId, canvasId: canvas.canvasId }); + mockSession.fire('session.canvas.opened', canvas, { id: 'open-3' }); - assert.throws(() => session.resolveCanvasSource('preview', 1), /not available/); + const states = signals.filter(signal => signal.kind === 'canvas'); assert.deepStrictEqual({ - revisions: canvasSnapshots.map(snapshot => snapshot.canvases[0]?.revision), - source: session.resolveCanvasSource('preview', 2), + states: states.map(signal => signal.state), + newLifetime: states[0].resource.toString() !== states[1].resource.toString(), + membershipCounts: getActions(signals).filter(action => action.type === ActionType.ChatCanvasesChanged).map(action => action.canvases?.length ?? 0), }, { - revisions: [1, 2], - source: 'https://example.test/live', + states: [canvas, canvas], + newLifetime: true, + membershipCounts: [1, 0, 1], }); session.dispose(); }); test('bounds the live canvas projection and evicts the oldest instance', async () => { - const { session, mockSession, canvasSnapshots } = await createAgentSession(disposables); + const { session, mockSession, signals } = await createAgentSession(disposables); for (let index = 0; index < 9; index++) { mockSession.fire('session.canvas.opened', { instanceId: `canvas-${index}`, @@ -2587,11 +2569,13 @@ suite('CopilotAgentSession', () => { }); } - const finalCanvases = canvasSnapshots.at(-1)?.canvases; - assert.throws(() => session.resolveCanvasSource('canvas-0', 1), /not available/); + const actions = getActions(signals).filter(action => action.type === ActionType.ChatCanvasesChanged); + const finalCanvases = actions.at(-1)?.canvases; + const states = signals.filter(signal => signal.kind === 'canvas'); + const survivingResources = new Set(finalCanvases?.map(canvas => canvas.resource)); assert.deepStrictEqual({ - instanceIds: finalCanvases?.map(canvas => canvas.instanceId), - newestSource: session.resolveCanvasSource('canvas-8', 9), + instanceIds: states.filter(signal => survivingResources.has(signal.resource.toString())).map(signal => signal.state.instanceId), + newestSource: states.at(-1)?.state.url, }, { instanceIds: ['canvas-1', 'canvas-2', 'canvas-3', 'canvas-4', 'canvas-5', 'canvas-6', 'canvas-7', 'canvas-8'], newestSource: 'https://example.test/canvas-8', diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index b9fc8523fb2a21..43f8ece5705cc9 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -22,9 +22,9 @@ import { NullLogService } from '../../../log/common/log.js'; import { FileType } from '../../../files/common/files.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; -import { AgentCanvasAvailability, type IAgentCanvasSnapshot, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agent.js'; +import { type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agent.js'; import { type IAgentHostManagedSettingsDiagnostics, type IAgentHostNetworkDiagnosticsInfo, type IAgentHostNetworkFetchResult, type IAgentService } from '../../common/agentService.js'; -import { AgentHostCanvasesChangedNotification, DevContainerConnectExtensionMethod, DevContainerDisconnectExtensionMethod, DevContainerIsDockerAvailableExtensionMethod, DevContainerOutputNotification, RemoveSessionArtifactExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, ResolveAgentHostCanvasSourceExtensionMethod, supportsAgentHostArtifactRemoval, supportsAgentHostCanvases, supportsAgentHostDevContainers, type IAgentHostMcpAuthenticationRequest } from '../../common/agentHostExtensionProtocol.js'; +import { DevContainerConnectExtensionMethod, DevContainerDisconnectExtensionMethod, DevContainerIsDockerAvailableExtensionMethod, DevContainerOutputNotification, RemoveSessionArtifactExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, supportsAgentHostArtifactRemoval, supportsAgentHostDevContainers, type IAgentHostMcpAuthenticationRequest } from '../../common/agentHostExtensionProtocol.js'; import { ChatSourceKind, CompletionsParams, CompletionsResult, ContentEncoding, CreateTerminalParams, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; import type { AutomationCapabilities, Implementation } from '../../common/state/protocol/common/commands.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../../common/state/protocol/channels-automation/commands.js'; @@ -191,9 +191,6 @@ class MockAgentService implements IAgentService { readonly onDidNotification = this._onDidNotification.event; private readonly _onMcpNotification = new Emitter(); readonly onMcpNotification = this._onMcpNotification.event; - private readonly _onDidChangeCanvases = new Emitter(); - readonly onDidChangeCanvases = this._onDidChangeCanvases.event; - readonly resolveCanvasSourceCalls: { chat: string; instanceId: string; revision: number }[] = []; private _stateManager!: AgentHostStateManager; @@ -251,11 +248,6 @@ class MockAgentService implements IAgentService { this.disposedChats.push({ session: session.toString(), chat: chat.toString() }); this._stateManager.removeChat(session.toString(), chat.toString()); } - async resolveCanvasSource(chat: URI, instanceId: string, revision: number): Promise { - this.resolveCanvasSourceCalls.push({ chat: chat.toString(), instanceId, revision }); - return 'https://example.test/canvas'; - } - fireCanvasSnapshot(snapshot: IAgentCanvasSnapshot): void { this._onDidChangeCanvases.fire(snapshot); } async listSessions(): Promise { const result = [...this.listedSessions]; this.afterListSessionsSnapshot?.(); @@ -360,7 +352,6 @@ class MockAgentService implements IAgentService { this._onDidAction.dispose(); this._onDidNotification.dispose(); this._onMcpNotification.dispose(); - this._onDidChangeCanvases.dispose(); } } @@ -543,125 +534,6 @@ suite('ProtocolServerHandler', () => { }); }); - test('canvas extension is local-only, publishes full snapshots, and fences source resolution', async () => { - const snapshot: IAgentCanvasSnapshot = { - chat: URI.parse(defaultChatUri), - canvases: [{ - instanceId: 'preview-1', - extensionId: 'project:preview', - canvasId: 'preview', - revision: 4, - availability: AgentCanvasAvailability.Ready, - }], - }; - agentService.fireCanvasSnapshot(snapshot); - const remote = connectClient('canvas-remote', undefined, undefined, { - 'vscode.clientConnectionKind': AgentHostClientConnectionKind.Local, - }, AgentHostTransportKind.WebSocket); - const local = connectClient('canvas-local', undefined, undefined, { - 'vscode.clientConnectionKind': AgentHostClientConnectionKind.Local, - }, AgentHostTransportKind.MessagePort); - const remoteInitialize = findResponse(remote.sent, 1); - const localInitialize = findResponse(local.sent, 1); - assert.ok(remoteInitialize && hasKey(remoteInitialize, { result: true })); - assert.ok(localInitialize && hasKey(localInitialize, { result: true })); - - const sourceResponse = waitForResponse(local, 2); - local.simulateMessage(request(2, ResolveAgentHostCanvasSourceExtensionMethod, { - chat: defaultChatUri, - instanceId: 'preview-1', - revision: 4, - })); - agentService.fireCanvasSnapshot(snapshot); - const remoteSourceResponse = waitForResponse(remote, 2); - remote.simulateMessage(request(2, ResolveAgentHostCanvasSourceExtensionMethod, { - chat: defaultChatUri, - instanceId: 'preview-1', - revision: 4, - })); - - assert.deepStrictEqual({ - capabilities: { - local: supportsAgentHostCanvases(localInitialize.result as InitializeResult), - remote: supportsAgentHostCanvases(remoteInitialize.result as InitializeResult), - }, - localSnapshots: findNotifications(local.sent, AgentHostCanvasesChangedNotification).map(notification => notification.params), - remoteSnapshots: findNotifications(remote.sent, AgentHostCanvasesChangedNotification).length, - sourceResponse: await sourceResponse, - remoteSourceResponse: await remoteSourceResponse, - resolveCalls: agentService.resolveCanvasSourceCalls, - }, { - capabilities: { local: true, remote: false }, - localSnapshots: [{ chat: defaultChatUri, canvases: snapshot.canvases }], - remoteSnapshots: 0, - sourceResponse: { jsonrpc: '2.0', id: 2, result: { url: 'https://example.test/canvas' } }, - remoteSourceResponse: { - jsonrpc: '2.0', - id: 2, - error: { code: JsonRpcErrorCodes.MethodNotFound, message: `Method not found: ${ResolveAgentHostCanvasSourceExtensionMethod}` }, - }, - resolveCalls: [{ chat: defaultChatUri, instanceId: 'preview-1', revision: 4 }], - }); - }); - - test('canvas reconnect replays current snapshots and clears snapshots removed while disconnected', async () => { - const removedChat = URI.parse(defaultChatUri); - const currentChat = URI.parse(buildChatUri('copilotcli:/session-1', 'peer-1')); - const removedSnapshot: IAgentCanvasSnapshot = { - chat: removedChat, - canvases: [{ - instanceId: 'removed', - extensionId: 'project:preview', - canvasId: 'preview', - revision: 1, - availability: AgentCanvasAvailability.Ready, - }], - }; - const currentSnapshot: IAgentCanvasSnapshot = { - chat: currentChat, - canvases: [{ - instanceId: 'current', - extensionId: 'project:preview', - canvasId: 'preview', - revision: 1, - availability: AgentCanvasAvailability.Ready, - }], - }; - agentService.fireCanvasSnapshot(removedSnapshot); - agentService.fireCanvasSnapshot(currentSnapshot); - const initial = connectClient('canvas-reconnect', undefined, undefined, { - 'vscode.clientConnectionKind': AgentHostClientConnectionKind.Local, - }, AgentHostTransportKind.MessagePort); - initial.simulateClose(); - - agentService.fireCanvasSnapshot({ chat: removedChat, canvases: [] }); - const updatedCurrentSnapshot: IAgentCanvasSnapshot = { - ...currentSnapshot, - canvases: [{ ...currentSnapshot.canvases[0], revision: 2 }], - }; - agentService.fireCanvasSnapshot(updatedCurrentSnapshot); - - const reconnected = new MockProtocolTransport(AgentHostTransportKind.MessagePort); - server.simulateConnection(reconnected); - const responsePromise = waitForResponse(reconnected, 2); - reconnected.simulateMessage(request(2, 'reconnect', { - clientId: 'canvas-reconnect', - lastSeenServerSeq: stateManager.serverSeq, - subscriptions: [], - _meta: { 'vscode.clientConnectionKind': AgentHostClientConnectionKind.Local }, - })); - await responsePromise; - await handler.whenIdle(); - - assert.deepStrictEqual( - findNotifications(reconnected.sent, AgentHostCanvasesChangedNotification).map(notification => notification.params), - [ - { chat: currentChat.toString(), canvases: updatedCurrentSnapshot.canvases }, - { chat: removedChat.toString(), canvases: [] }, - ], - ); - }); - test('Dev Containers enforce initiating transport trust, ownership, and disconnect cleanup', async () => { const first = connectClient('client-1'); const second = connectClient('client-1'); diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts index 9150016d26fbc0..cceeb6ba8753f6 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts @@ -16,11 +16,13 @@ import { join } from '../../../../../base/common/path.js'; import { isWindows } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; import { AgentHostCanvasesEnabledConfigKey } from '../../../common/agentHostSchema.js'; +import { hasKey } from '../../../../../base/common/types.js'; import { ActionType, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatTurnStartedAction } from '../../../common/state/sessionActions.js'; -import { type SubscribeResult } from '../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; import { buildDefaultChatUri, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, SessionStatus, ToolCallContributorKind, ToolResultContentType, type ISessionWithDefaultChat, type RootState, type ToolDefinition } from '../../../common/state/sessionState.js'; import { ToolCallConfirmationReason } from '../../../common/state/protocol/channels-chat/state.js'; +import type { CanvasState } from '../../../common/state/protocol/channels-canvas/state.js'; +import type { SubscribeResult } from '../../../common/state/protocol/common/commands.js'; import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; import { AgentHostSessionReleaseRetryMsEnvVar, AgentHostSessionResidencyLimitEnvVar } from '../../../common/agentService.js'; import { createProviderSession, dispatchTurn, type IAgentHostProviderTestConfig } from '../providerIntegrationTestHelpers.js'; @@ -241,6 +243,19 @@ session = await joinSession({ 90_000, ); const openCanvasComplete = getActionEnvelope(openCanvasCompleteNotification).action as ChatToolCallCompleteAction; + const canvasNotification = await client.waitForNotification(n => + isActionNotification(n, ActionType.ChatCanvasesChanged) + && getActionEnvelope(n).action.type === ActionType.ChatCanvasesChanged, + 90_000, + ); + const canvasAction = getActionEnvelope(canvasNotification).action; + assert.ok(canvasAction.type === ActionType.ChatCanvasesChanged); + const canvasReference = canvasAction.canvases?.[0]; + assert.ok(canvasReference); + const snapshot = await client.call('subscribe', { channel: canvasReference.resource }); + const state = snapshot.snapshot?.state; + assert.ok(state && hasKey(state, { instanceId: true })); + const canvas: CanvasState = state; await client.waitForNotification(n => isActionNotification(n, ActionType.ChatTurnComplete) && (getActionEnvelope(n).action as ChatTurnCompleteAction).turnId === 'turn-mock-canvas', @@ -273,12 +288,16 @@ session = await joinSession({ assert.deepStrictEqual({ openCanvasSucceeded: openCanvasComplete.result.success, + canvas: { instanceId: canvas.instanceId, url: canvas.url }, + referencesContainOnlyResource: canvasAction.canvases?.every(reference => Object.keys(reference).length === 1), providerTurn: { message: providerTurnStarted.message, response: providerResponse.part.kind === ResponsePartKind.Markdown ? providerResponse.part.content : undefined, }, }, { openCanvasSucceeded: true, + canvas: { instanceId: 'proof-instance', url: 'http://127.0.0.1:43119/proof-instance' }, + referencesContainOnlyResource: true, providerTurn: { message: { text: `[scenario:${CANVAS_EXTENSION_TURN_SCENARIO_ID}] Report that the canvas request completed.`, diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 653d357277c12c..0253875940371e 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -133,7 +133,7 @@ The Agents Window and editor-window Agent Host inputs use the same workbench-own Providers may advertise `supportsRemoveArtifacts` and implement `removeSessionArtifact`. User-initiated removal routes through `ISessionsManagementService` to the owning provider, which persists and publishes the updated artifact list. Removing a record does not remove independent session associations or alter the linked resource. -Chats may expose live model-opened canvases through an observable provider-neutral collection when the session advertises canvas support. Each entry carries stable identity, presentation metadata, availability, and a read-only source resolver; transient source URLs and provider process details remain inside the provider. Durable local Copilot sessions always admit the extension and canvas runtime, while ephemeral and remote sessions expose no canvas capability. Closing presentation does not invoke provider operations or persist canvas membership. +Chats may expose server-published live canvases through an observable provider-neutral collection when the owning provider permits canvas presentation for that session. Each chat discovers membership from its advertised canvas channel references; each subscribed channel supplies presentation metadata and an optional live HTTP(S) source. Unknown membership is distinct from an authoritative empty collection, and pending canvas state retains its resource without a source. The local Agent Host provider enables presentation only for durable Copilot sessions, while ephemeral and remote sessions expose no canvas capability. Canvas execution and presentation continue to follow the existing canvas enablement setting. Closing presentation dismisses that canvas lifetime without invoking provider operations. Metadata and source updates do not reopen a dismissed canvas. Canvas membership and sources are not persisted across restarts. Recorded GitHub issue and pull request artifacts are resolved from `ISession.artifacts` independently of workspace or repository availability, alongside repository-discovered associations. Chat-scoped surfaces use records owned by the focused chat plus unowned session records, while session-wide consumers use the aggregate; repository associations follow the focused chat's workspace or the session workspace respectively. Recorded references never enter the dedicated pull request and issue pills, even when a provider echoes them into its GitHub metadata; they always stay in the references pill. A chat's pull request pill shows the pull requests of its folders' repositories; recorded pull requests from other repositories remain in the artifacts list. The dedicated pills, artifact de-duplication, and pull-request polling share this resolution. Promoted entries retain their optional recorded-reference ID; presentation uses that ID for per-item removal, names the removal after whether the record is an artifact or a reference, and never infers record identity from a title or URL. diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts index 581050b5142cb9..8a3671294f0339 100644 --- a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts @@ -11,6 +11,8 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, observableValue } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType, IAccessibleViewService } from '../../../../platform/accessibility/browser/accessibleView.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; @@ -27,7 +29,6 @@ import { AccessibilityVerbositySettingId } from '../../../../workbench/contrib/a import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; import { focusWebContentsViewContainer, WebContentsViewHost } from '../../../../workbench/contrib/browserView/electron-browser/webContentsViewHost.js'; import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; -import { SessionCanvasAvailability } from '../../../services/sessions/common/session.js'; import { ISessionCanvasService, SessionCanvasInput } from '../common/sessionCanvas.js'; export const SessionCanvasFocusedContext = new RawContextKey('sessionCanvasFocused', false); @@ -109,11 +110,11 @@ export class SessionCanvasEditor extends EditorPane { this._detach(localize('canvas.closed', "This canvas is no longer available.")); return; } - if (canvas.availability !== SessionCanvasAvailability.Ready) { + if (canvas.source === undefined) { this._detach(localize('canvas.unavailable', "The canvas provider is temporarily unavailable.")); return; } - const key = `${canvas.resource.toString()}\u0000${canvas.revision}`; + const key = `${canvas.resource.toString()}\u0000${canvas.source.toString()}`; if (this.loadingKey !== key && this.loadedKey !== key) { void this._load(input, key); } @@ -130,19 +131,16 @@ export class SessionCanvasEditor extends EditorPane { private async _load(input: SessionCanvasInput, key: string): Promise { const canvas = input.canvas.get(); - if (!canvas) { + if (canvas === undefined || canvas.source === undefined) { return; } this._detach(localize('canvas.loading', "Loading canvas…")); const sequence = ++this.loadSequence; this.loadingKey = key; try { - const source = await canvas.resolveSource(); - if (!this._isCurrent(input, canvas.revision, sequence)) { - return; - } + const source = canvas.source; const model = await this.browserViewService.createExternalBrowserView(source.toString(true)); - if (!this._isCurrent(input, canvas.revision, sequence)) { + if (!this._isCurrent(input, source, sequence)) { model.dispose(); return; } @@ -151,23 +149,23 @@ export class SessionCanvasEditor extends EditorPane { this.message.textContent = localize('canvas.pageLoading', "Loading canvas page…"); this.loadedKey = key; this.message.textContent = model.error ? localize('canvas.pageFailed', "The canvas page failed to load.") : ''; - } catch (error) { - if (this._isCurrent(input, canvas.revision, sequence)) { + } catch { + if (this._isCurrent(input, canvas.source, sequence)) { this._detach(localize('canvas.loadFailed', "The canvas could not be loaded.")); - this.logService.error('[SessionCanvasEditor] Failed to load canvas', error); + this.logService.error('[SessionCanvasEditor] Failed to load canvas'); } } finally { - if (this.loadingKey === key) { + if (sequence === this.loadSequence && this.loadingKey === key) { this.loadingKey = undefined; } } } - private _isCurrent(input: SessionCanvasInput, revision: number, sequence: number): boolean { + private _isCurrent(input: SessionCanvasInput, source: URI, sequence: number): boolean { return !this._store.isDisposed && sequence === this.loadSequence && this.currentInput.get() === input - && input.canvas.get()?.revision === revision + && isEqual(input.canvas.get()?.source, source) && this.canvasService.isActiveOwner(input.reference); } @@ -212,8 +210,8 @@ export class SessionCanvasEditor extends EditorPane { this.host.setModel(undefined); const input = this.currentInput.get(); const canvas = input?.canvas.get(); - if (input && canvas?.availability === SessionCanvasAvailability.Ready && this.canvasService.isActiveOwner(input.reference)) { - void this._load(input, `${canvas.resource.toString()}\u0000${canvas.revision}`); + if (input && canvas?.source && this.canvasService.isActiveOwner(input.reference)) { + void this._load(input, `${canvas.resource.toString()}\u0000${canvas.source.toString()}`); } })); const visible = this.presentationVisible.get() || this.group.activeEditor === this.input; @@ -297,7 +295,7 @@ export class SessionCanvasEditor extends EditorPane { const help = [ localize('canvas.help.overview', "This canvas is a private page owned by the active conversation."), localize('canvas.help.navigation', "Tab moves through page controls. Use to leave the page."), - localize('canvas.help.close', "Closing the tab hides the canvas until the agent opens that instance again."), + localize('canvas.help.close', "Closing the tab hides this canvas. A newly created canvas can open another tab."), ].join('\n\n'); this.semanticText = localize('canvas.reading', "Reading accessible canvas content…"); return new AccessibleContentProvider( diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts index 5676dd4357f141..06cf625f862a56 100644 --- a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts @@ -13,7 +13,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { IChat, ISession, ISessionCanvas, SessionCanvasAvailability } from '../../../services/sessions/common/session.js'; +import { IChat, ISession, ISessionCanvas } from '../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionCanvasReference, ISessionCanvasService, ISessionCanvasTarget, SessionCanvasInput } from '../common/sessionCanvas.js'; @@ -24,8 +24,8 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe private readonly _inputs = this._register(new DisposableMap()); private readonly _inputLifetimes = this._register(new DisposableMap()); - private readonly _dismissedRevisions = new Map(); - private readonly _presentedRevisions = new Map(); + private readonly _dismissed = new Map(); + private readonly _presented = new Set(); private readonly _programmaticCloses = new Set(); constructor( @@ -55,11 +55,11 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe const activeSession = this.sessionsService.activeSession.read(reader); const activeChat = activeSession?.activeChat.read(reader); const supported = activeSession?.capabilities.read(reader).supportsCanvases === true; - const canvases = enabled && supported ? activeChat?.canvases?.read(reader) ?? [] : []; + const canvases = enabled && supported ? activeChat?.canvases?.read(reader) : undefined; const activeKeys = new Set(); if (activeSession && activeChat && enabled && supported) { - for (const canvas of canvases) { + for (const canvas of canvases ?? []) { const reference: ISessionCanvasReference = { providerId: activeSession.providerId, session: activeSession.resource, @@ -70,28 +70,26 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe const key = canvasKey(reference); activeKeys.add(key); input.setCanvas(canvas); - if (canvas.availability !== SessionCanvasAvailability.Ready - || this._dismissedRevisions.get(key)?.revision === canvas.revision - || this._presentedRevisions.get(key) === canvas.revision) { + if (canvas.source === undefined || this._dismissed.has(key) || this._presented.has(key)) { continue; } - this._presentedRevisions.set(key, canvas.revision); + this._presented.add(key); void this.editorService.openEditor(input, { pinned: true, revealIfOpened: true, preserveFocus: false }) .catch(error => this.logService.error('[SessionCanvasService] Failed to reveal canvas', error)); } } - if (activeSession && activeChat) { - for (const [key, dismissed] of this._dismissedRevisions) { - if (ownsChat(dismissed.reference, activeSession, activeChat) && !activeKeys.has(key)) { - this._dismissedRevisions.delete(key); + if (activeSession && activeChat && canvases !== undefined) { + for (const [key, dismissed] of this._dismissed) { + if (ownsChat(dismissed, activeSession, activeChat) && !activeKeys.has(key)) { + this._dismissed.delete(key); } } } for (const [key, input] of this._inputs) { const ownsActiveChat = !!activeSession && !!activeChat && ownsChat(input.reference, activeSession, activeChat); - if (!enabled || (ownsActiveChat && !activeKeys.has(key))) { + if (!enabled || (ownsActiveChat && (!supported || (canvases !== undefined && !activeKeys.has(key))))) { void this._closeInput(key, input); } } @@ -100,14 +98,15 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe getTarget(reference: ISessionCanvasReference, reader?: IReader): ISessionCanvasTarget | undefined { const session = this.sessionsService.activeSession.read(reader); - if (!session || session.providerId !== reference.providerId || !isEqual(session.resource, reference.session)) { + if (!session || session.capabilities.read(reader).supportsCanvases !== true + || session.providerId !== reference.providerId || !isEqual(session.resource, reference.session)) { return undefined; } const chat = session.activeChat.read(reader); if (!isEqual(chat.resource, reference.chat)) { return undefined; } - const canvas = chat.canvases?.read(reader).find(candidate => isEqual(candidate.resource, reference.canvas)); + const canvas = chat.canvases?.read(reader)?.find(candidate => isEqual(candidate.resource, reference.canvas)); return canvas ? { session, chat, canvas } : undefined; } @@ -127,10 +126,7 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe this._inputLifetimes.set(key, lifetime); lifetime.add(Event.once(input.onWillDispose)(() => { if (!this._programmaticCloses.delete(key)) { - const revision = input?.canvas.get()?.revision; - if (revision !== undefined) { - this._dismissedRevisions.set(key, { reference, revision }); - } + this._dismissed.set(key, reference); } if (this._inputs.get(key) === input) { this._inputs.deleteAndLeak(key); @@ -138,16 +134,16 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe if (this._inputLifetimes.get(key) === lifetime) { this._inputLifetimes.deleteAndLeak(key); } - this._presentedRevisions.delete(key); + this._presented.delete(key); lifetime.dispose(); })); return input; } private _removeSession(session: ISession): void { - for (const [key, dismissed] of this._dismissedRevisions) { - if (ownsSession(dismissed.reference, session)) { - this._dismissedRevisions.delete(key); + for (const [key, dismissed] of this._dismissed) { + if (ownsSession(dismissed, session)) { + this._dismissed.delete(key); } } for (const [key, input] of this._inputs) { @@ -169,8 +165,8 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe if (lifetime && this._inputLifetimes.get(key) === lifetime) { this._inputLifetimes.deleteAndLeak(key); } - this._dismissedRevisions.delete(key); - this._presentedRevisions.delete(key); + this._dismissed.delete(key); + this._presented.delete(key); await this.editorService.closeEditors(this.editorService.findEditors(input.resource), { preserveFocus: true }); if (!input.isDisposed()) { input.dispose(); diff --git a/src/vs/sessions/contrib/canvases/test/common/sessionCanvas.test.ts b/src/vs/sessions/contrib/canvases/test/common/sessionCanvas.test.ts index 5df7d35aef3a05..0b6548b127119a 100644 --- a/src/vs/sessions/contrib/canvases/test/common/sessionCanvas.test.ts +++ b/src/vs/sessions/contrib/canvases/test/common/sessionCanvas.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ISessionCanvas, SessionCanvasAvailability } from '../../../../services/sessions/common/session.js'; +import { ISessionCanvas } from '../../../../services/sessions/common/session.js'; import { SessionCanvasInput } from '../../common/sessionCanvas.js'; suite('SessionCanvasInput', () => { @@ -14,24 +14,22 @@ suite('SessionCanvasInput', () => { test('keeps transient sources out of editor identity and does not restore', () => { const source = 'https://secret.example/canvas?token=sensitive'; - const canvas = (title: string, revision: number): ISessionCanvas => ({ + const canvas = (title: string): ISessionCanvas => ({ resource: URI.parse('agent-host-canvas:/preview'), instanceId: 'preview', title, - revision, - availability: SessionCanvasAvailability.Ready, - resolveSource: async () => URI.parse(source), + source: URI.parse(source), }); const input = store.add(new SessionCanvasInput({ providerId: 'local-agent-host', session: URI.parse('agent-host-session:/session'), chat: URI.parse('agent-host-chat:/session/main'), canvas: URI.parse('agent-host-canvas:/preview'), - }, canvas('Preview', 1))); + }, canvas('Preview'))); const labels: string[] = []; store.add(input.onDidChangeLabel(() => labels.push(input.getName()))); - input.setCanvas(canvas('Updated Preview', 2)); + input.setCanvas(canvas('Updated Preview')); assert.deepStrictEqual({ name: input.getName(), diff --git a/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts b/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts index b7ed3f4693d264..83b78a8dae47b5 100644 --- a/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts +++ b/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts @@ -16,7 +16,7 @@ import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; -import { IChat, ISessionCanvas, ISessionCapabilities, SessionCanvasAvailability } from '../../../../services/sessions/common/session.js'; +import { IChat, ISessionCanvas, ISessionCapabilities } from '../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { SessionCanvasInput } from '../../common/sessionCanvas.js'; import { SessionCanvasService } from '../../electron-browser/sessionCanvasService.js'; @@ -31,11 +31,9 @@ suite('SessionCanvasService', () => { resource: URI.parse('agent-host-canvas:/preview'), instanceId: 'preview', title: 'Preview', - revision: 1, - availability: SessionCanvasAvailability.Ready, - resolveSource: async () => URI.parse('https://example.test/preview'), + source: URI.parse('https://example.test/preview'), }; - const canvases = observableValue('canvases', [canvas]); + const canvases = observableValue('canvases', [canvas]); const chat = upcastPartial({ resource: chatResource, canvases }); const activeChat = observableValue('activeChat', chat); const capabilities = observableValue('capabilities', { supportsCanvases: true, supportsMultipleChats: false }); @@ -107,7 +105,7 @@ suite('SessionCanvasService', () => { assert.deepStrictEqual({ opened: harness.opened.length, closed: harness.closeCount }, { opened: 1, closed: 1 }); }); - test('forgets a dismissed revision when the provider removes the canvas', () => { + test('forgets a dismissed canvas when the provider removes its membership', () => { const { canvas, canvases, opened } = createHarness(); opened[0].dispose(); @@ -117,23 +115,36 @@ suite('SessionCanvasService', () => { assert.strictEqual(opened.length, 2); }); - test('reveals a dismissed canvas when the provider publishes a new open revision', () => { - const { canvas, canvases, opened } = createHarness(); + test('forgets dismissed canvases when the owning session disappears', () => { + const { activeSession, opened, session, sessionChanges } = createHarness(); opened[0].dispose(); + activeSession.set(undefined, undefined); - canvases.set([{ ...canvas, revision: 2 }], undefined); + sessionChanges.fire({ added: [], changed: [], removed: [session] }); + activeSession.set(session, undefined); assert.strictEqual(opened.length, 2); }); - test('forgets dismissed revisions when the owning session disappears', () => { - const { activeSession, opened, session, sessionChanges } = createHarness(); + test('metadata and source changes do not reveal a dismissed canvas', () => { + const { canvas, canvases, opened } = createHarness(); opened[0].dispose(); - activeSession.set(undefined, undefined); + canvases.set([{ ...canvas, title: 'Updated', source: URI.parse('https://example.test/replacement') }], undefined); + assert.strictEqual(opened.length, 1); + }); - sessionChanges.fire({ added: [], changed: [], removed: [session] }); - activeSession.set(session, undefined); + test('metadata and source changes do not repeatedly reveal an open canvas', () => { + const { canvas, canvases, opened } = createHarness(); + canvases.set([{ ...canvas, title: 'Updated', source: URI.parse('https://example.test/replacement') }], undefined); + assert.strictEqual(opened.length, 1); + }); - assert.strictEqual(opened.length, 2); + test('temporary membership and canvas hydration do not forget dismissal', () => { + const { canvas, canvases, opened } = createHarness(); + opened[0].dispose(); + canvases.set(undefined, undefined); + canvases.set([{ ...canvas, instanceId: undefined, source: undefined }], undefined); + canvases.set([canvas], undefined); + assert.strictEqual(opened.length, 1); }); }); diff --git a/src/vs/sessions/contrib/layout/test/browser/desktopStrategies.test.ts b/src/vs/sessions/contrib/layout/test/browser/desktopStrategies.test.ts index f4c588e163ea41..23767d885e6931 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopStrategies.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopStrategies.test.ts @@ -18,7 +18,7 @@ import { WebviewInput } from '../../../../../workbench/contrib/webviewPanel/brow import { IEditorGroup, IEditorGroupsService } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; -import { SessionCanvasAvailability, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { SessionCanvasInput } from '../../../canvases/common/sessionCanvas.js'; import { EmptyFileEditorInput } from '../../../editor/browser/emptyFileEditorInput.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../../files/browser/files.contribution.js'; @@ -247,9 +247,7 @@ suite('Desktop layout strategies', () => { resource: canvasResource, instanceId: 'preview', title: 'Preview', - revision: 1, - availability: SessionCanvasAvailability.Ready, - resolveSource: async () => URI.parse('https://example.test/preview'), + source: URI.parse('https://example.test/preview'), })); harness.activeGroupEditors.push(canvasEditor); harness.activeEditorInput = canvasEditor; diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index b89803732620e8..12174f05c21307 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -95,7 +95,7 @@ Each listed chat also carries a compact Session Changes aggregate in the session Agent-recorded artifacts and references are persisted with the session and projected together through `ISession.artifacts`, where `chat` preserves each record's provenance and `isArtifact` distinguishes it for presentation (dedicated pill vs. reference collection) only, not for removability. Shared GitHub surfaces resolve recorded pull requests and issues independently of workspace availability; chat-scoped surfaces select the focused chat's records plus unowned session records, while session-wide surfaces use the aggregate. The provider also promotes matching entries into the session folder's GitHub metadata only, without assigning unrelated links to its repository. A pull request artifact recorded from a chat is associated with that chat's working folder when the host verifies its repository and head branch against the checkout. Other folders report only their own discovered, explicitly created, or verified PR associations. Promoted entries retain their stable recorded-reference ID regardless of `isArtifact`, and presentation uses that ID for session-only removal; a git-/session-discovered GitHub association that was never recorded through `add_artifact_or_reference` has no recorded-reference ID and stays non-removable, even if it reappears after a recorded duplicate is removed. Customizations used or read by the agent are derived per chat and projected through `IChat.customizations`. -Canvas support is negotiated through a versioned VS Code-specific Agent Host extension capability and remains available only for durable local Copilot sessions. The extension transport publishes full-replacement snapshots for exact chat URIs and resolves revision-fenced HTTP(S) sources on demand; remote and unsupported hosts expose no Sessions capability. Canonical AHP state contains neither canvas membership nor source URLs. +Live canvas membership is discovered from minimal resource references published by the exact chat channel, not from an agent root capability. Each advertised resource has an independently experimental canvas channel containing presentation metadata and its optional live URL. The provider subscribes to those exact resources while the session is observed and projects provider-neutral state through `IChat.canvases`. Channel hydration or failure does not remove advertised membership; it leaves the source unavailable. Diagnostic protocol logs redact live URLs. The local Agent Host provider enables presentation for durable Copilot sessions as provider policy; remote presentation remains unsupported. ## Draft and send lifecycle @@ -111,7 +111,7 @@ create draft The first send waits for tracked draft configuration. Cancellation disposes the draft. Later configuration changes are scoped to the committed session and do not recreate the entire facade. -For non-ephemeral Copilot chats, normal SDK create and resume request runtime extensions and canvas rendering after the existing Workspace Trust admission. The Copilot client resolves its release-aligned runtime, bootstrap, and extension SDK assets once per client residency; session create and resume carry that resolved SDK path so the runtime can configure admitted extension processes. Session launch does not rediscover application package paths. The extension launch provider supplies the bootstrap command and a stable absolute `VSCODE_CANVAS_DATA_DIR` beneath the Agent Host user-data path for extension-owned state. Extension readiness settles before the relevant model turn. Copilot receives an eager `extensions_reload` tool backed by the live session extension RPC, so an agent can create or edit an extension, reload providers, verify its canvas capabilities, and open it in the same turn. Reload temporarily makes existing extension canvases unavailable; the runtime rehydrates their open instances when providers reconnect. A new SDK residency starts with an empty projected canvas collection and does not restore prior open canvases; source resolution never resumes a provider or replays an open or action. The host retains at most eight projected canvases per chat, evicting the oldest projection when the limit is reached. +When `chat.agentHost.canvases.enabled` admits canvas execution for non-ephemeral Copilot chats, normal SDK create and resume request runtime extensions and canvas rendering after the existing Workspace Trust admission. The Copilot client resolves its release-aligned runtime, bootstrap, and extension SDK assets once per client residency; session create and resume carry that resolved SDK path so the runtime can configure admitted extension processes. Session launch does not rediscover application package paths. The extension launch provider supplies the bootstrap command and a stable absolute `VSCODE_CANVAS_DATA_DIR` beneath the Agent Host user-data path for extension-owned state. Extension readiness settles before the relevant model turn. Copilot receives an eager `extensions_reload` tool backed by the live session extension RPC, so an agent can create or edit an extension, reload providers, verify its canvas capabilities, and open it in the same turn. Reload temporarily makes existing extension canvases unavailable; the runtime rehydrates their open instances when providers reconnect. A new SDK residency starts with an empty projected canvas collection and does not restore prior open canvases. Canvas subscriptions only read existing live state, pin their owning session, and never resume a provider or replay an open or action. The host retains at most eight projected canvases per chat, evicting the oldest projection when the limit is reached. Removing chat membership or its owning chat removes the live canvas channel. The host returns the complete ordered local branch list for branch completions; clients filter and limit it. For workspace-bound drafts, the provider starts loading branches when the draft is created and serves the result through its existing configuration-completions API. The new-session pickers search it locally on desktop and phone; load errors are reported rather than treated as an empty list. Replacing or disposing the draft discards the loaded list. Running sessions request a fresh list when their picker opens. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index b9b03ca2cca3c6..ef572ae8f4f223 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { arrayEqualsC, structuralEquals } from '../../../../../base/common/equals.js'; +import { Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { constObservable, derived, derivedObservableWithCache, derivedOpts, IObservable, IReader, mapObservableArrayCached, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { basename, extUriBiasedIgnorePathCase, isEqual } from '../../../../../base/common/resources.js'; @@ -263,7 +264,7 @@ export function createActiveSessionSubscriptionObs( const subscriptionRef = reader.store.add(connection.getSubscription(component, resource, 'AgentHostSessionChangesets')); - return observableFromEvent(subscriptionRef.object.onDidChange, + return observableFromEvent(Event.any(subscriptionRef.object.onDidChange, subscriptionRef.object.onDidError ?? Event.None), () => subscriptionRef.object.value as T | Error | undefined); }); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 74a9398ffd649d..89f2c5c2032ef0 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -13,14 +13,14 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, Mu import { mapsStrictEqualIgnoreOrder, ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { deepClone, equals } from '../../../../../base/common/objects.js'; -import { constObservable, derived, derivedOpts, IObservable, IReader, ISettableObservable, ITransaction, observableFromEvent, observableSignal, observableSignalFromEvent, observableValueOpts, subtransaction, transaction, waitForState, autorun, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, derived, derivedOpts, IObservable, IReader, ISettableObservable, ITransaction, mapObservableArrayCached, observableFromEvent, observableSignal, observableSignalFromEvent, observableValueOpts, subtransaction, transaction, waitForState, autorun, observableValue } from '../../../../../base/common/observable.js'; import { basename, dirname, extUriIgnorePathCase, getComparisonKey, isEqual, isEqualOrParent, joinPath, relativePath } from '../../../../../base/common/resources.js'; import { themeColorFromId, ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { findDevContainerSample } from '../../../../../platform/agentHost/common/devContainerSamples.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; -import { AgentCanvasAvailability, AgentSession, AuthenticateParams, AuthenticateResult, CODEX_AGENT_PROVIDER_ID, type IAgentCanvas, type IAgentCanvasSnapshot, type IAgentSessionChatMetadata, IAgentSessionMetadata, protectedResourcesRequireGitHubCopilotSignIn } from '../../../../../platform/agentHost/common/agent.js'; +import { AgentSession, AuthenticateParams, AuthenticateResult, CODEX_AGENT_PROVIDER_ID, type IAgentSessionChatMetadata, IAgentSessionMetadata, protectedResourcesRequireGitHubCopilotSignIn } from '../../../../../platform/agentHost/common/agent.js'; import { AgentMergeSessionOverrides, AgentMergeSessionState, readAgentMergeFolderState, readAgentMergeFolderStates } from '../../../../../platform/agentHost/common/agentMerge.js'; import { readAgentSdkSetupInfos } from '../../../../../platform/agentHost/common/agentSdkSetup.js'; import { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; @@ -50,7 +50,7 @@ import { readSessionSandboxPolicy, type ISessionSandboxPolicy } from '../../../. import { readSessionSandboxState } from '../../../../../platform/agentHost/common/meta/agentSandboxStateMeta.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ResolveSessionConfigResult, type SessionConfigPropertySchema, type SessionConfigSchema, type SessionConfigValueItem } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ChatOrigin, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, McpServerStatus, MessageKind, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, type SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type CanvasReference, type CanvasState, type ChatOrigin, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, McpServerStatus, MessageKind, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, type SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { isActionKnownToVersion } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { ActionType, isChatAction, isSessionAction, NotificationType, type SessionSummaryChanges } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionCreationReference, readSessionEhcliAdoptable, readFolderGitHubState, readFolderScopeGitState, readSessionExternal, parseSessionGitHubData, readSessionGitHubData, readSessionGitState, readWorkingDirectoryKey, readWorkingDirectoryKeys, readWorkingDirectoryScopeId, readWorkingDirectoryScopeIds, withMigratedSessionGitHubState, withSessionGitHubData, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionCreationReference, withSessionExternal, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, withWorkingDirectoryKey, withWorkingDirectoryScopeId, type ChatState, type ChatSummary, type ISessionCreationReference as IProtocolSessionCreationReference, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; @@ -77,11 +77,11 @@ import { isAutoApprovePolicyRestricted, normalizeSessionConfigValue } from '../. import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { getRegisteredLanguageModels, getVisibleLanguageModelsForTarget, resolveConfiguredModel, resolveModelIdentifier, resolveModelIdentifierFromLanguageModels } from '../../../../../workbench/contrib/chat/common/modelSelection.js'; import { canInitializeCodexWithoutGitHub } from '../../../../../workbench/services/agentHost/browser/codexAccountService.js'; -import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, IAgentMergeClientState, LOCAL_AGENT_HOST_PROVIDER_ID, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; +import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, IAgentMergeClientState, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey, buildAgentHostChatWorkspace, type IFolderGitHubInfoResolver } from '../../../../common/agentHostSessionWorkspace.js'; import { USE_WORKTREE_SETTING, isSessionConfigComplete } from '../../../../common/sessionConfig.js'; import { linkKey } from '../../../../common/sessionLinks.js'; -import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, getSessionOwnedGitHubPullRequestRefs, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, isActiveSessionStatus, ISession, ISessionAgentRef, ISessionApplication, ISessionEnvironment, ISessionArtifact, ISessionCanvas, ISessionCapabilities, ISessionChangesSummary, ISessionChatCustomization, ISessionChangeset, ISessionCreationReference, ISessionFileChange, ISessionPreparationProgress, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionCanvasAvailability, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus, SessionTypeAuthRequirement, toSessionId } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, getSessionOwnedGitHubPullRequestRefs, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, isActiveSessionStatus, ISession, ISessionAgentRef, ISessionApplication, ISessionEnvironment, ISessionArtifact, ISessionCanvas, ISessionCapabilities, ISessionChangesSummary, ISessionChatCustomization, ISessionChangeset, ISessionCreationReference, ISessionFileChange, ISessionPreparationProgress, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus, SessionTypeAuthRequirement, toSessionId } from '../../../../services/sessions/common/session.js'; import { dedupeLinks, partitionSessionArtifacts, type IRecordedGitHubReference } from './agentHostSessionArtifacts.js'; import { getWorktreeDiskUsage } from './worktreeDiskUsage.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; @@ -100,7 +100,6 @@ const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.session const STORAGE_KEY_REMEMBERED_WORKSPACE_ISOLATIONS = 'sessions.agentHost.sessionConfigPicker.workspaceIsolations'; const UNSAFE_SESSION_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']); const SESSION_CHANGE_NOTIFICATION_DEBOUNCE_MS = 50; -const AGENT_HOST_CANVAS_SCHEME = 'agent-host-canvas'; /** * Session config properties derived from settings (see `_derivedNewSessionConfig`), @@ -421,7 +420,7 @@ function chatMetadataFromSummary(summary: Pick URI | undefined; /** Agent capability lookup shared by every adapter owned by this provider. */ readonly agentCapabilities: IObservable | undefined>; - /** Live canvas snapshots published through the local VS Code Agent Host extension contract. */ - readonly canvasSnapshots?: IObservable>; + /** Whether this provider can present server-published canvas channels for the agent. */ + readonly supportsCanvasPresentation?: (agentProvider: string) => boolean; /** * The scheme the host addresses this session under, when it differs from the agent provider * (cloud sandbox: provider `copilot`, sessions `ahp-session:/`). Defaults to the provider. @@ -933,7 +932,7 @@ function toChatInteractivity(interactivity: ProtocolChatInteractivity | undefine interface IChatOutputObs { readonly lastTurnChanges: IObservable; readonly customizations: IObservable; - readonly canvases: IObservable; + readonly canvases: IObservable; /** Resolves the GitHub info each folder of the chat's workspace reports. */ readonly getFolderGitHubInfo: (reader: IReader) => IFolderGitHubInfoResolver; /** Resolves the Git state persisted for the chat's working-directory scope. */ @@ -942,44 +941,32 @@ interface IChatOutputObs { class AgentHostSessionCanvas implements ISessionCanvas { readonly resource: URI; - readonly instanceId: string; + readonly instanceId: string | undefined; readonly title: string; readonly status: string | undefined; - readonly revision: number; - readonly availability: SessionCanvasAvailability; + readonly source: URI | undefined; constructor( - private readonly _chat: URI, - canvas: IAgentCanvas, - private readonly _getConnection: () => IAgentConnection | undefined, + resource: URI, + canvas: CanvasState | undefined, + @ILogService logService: ILogService, ) { - this.resource = URI.from({ - scheme: AGENT_HOST_CANVAS_SCHEME, - path: `/${encodeURIComponent(_chat.toString())}/${encodeURIComponent(canvas.instanceId)}`, - }); - this.instanceId = canvas.instanceId; - this.title = canvas.title ?? canvas.extensionName ?? canvas.canvasId; - this.status = canvas.status; - this.revision = canvas.revision; - this.availability = canvas.availability === AgentCanvasAvailability.Ready - ? SessionCanvasAvailability.Ready - : SessionCanvasAvailability.Unavailable; - } - - async resolveSource(): Promise { - const connection = this._getConnection(); - if (!connection) { - throw new Error(localize('agentHostCanvas.disconnected', "The canvas runtime is disconnected.")); - } - const canvases = connection.canvases; - if (!canvases) { - throw new Error(localize('agentHostCanvas.unavailable', "The canvas runtime is unavailable.")); - } - const source = URI.parse(await canvases.resolveSource(this._chat, this.instanceId, this.revision), true); - if (source.scheme !== Schemas.http && source.scheme !== Schemas.https) { - throw new Error(localize('agentHostCanvas.invalidSource', "The canvas returned an unsupported source.")); + this.resource = resource; + this.instanceId = canvas?.instanceId; + this.title = canvas?.title ?? canvas?.extensionName ?? canvas?.canvasId ?? localize('canvas.pendingTitle', "Canvas"); + this.status = canvas?.status; + if (canvas?.url !== undefined) { + try { + const source = URI.parse(canvas.url, true); + if ((source.scheme === Schemas.http || source.scheme === Schemas.https) && source.authority) { + this.source = source; + } else { + logService.warn('[AgentHostSessionCanvas] Unsupported canvas source'); + } + } catch { + logService.warn('[AgentHostSessionCanvas] Invalid canvas source'); + } } - return source; } } @@ -1256,14 +1243,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { readonly mainChat: IObservable; readonly chats: IObservable; /** - * Capabilities derived reactively from the connection's root state rather - * than snapshotted at construction time. The root state can still be loading - * when an adapter is built (the agent-host process may be starting), in which - * case the agent's advertised capabilities are not yet available; the derived - * re-emits (and drives the chat catalog / context keys) as soon as the root - * state arrives instead of being permanently frozen to the `false` defaults. - * `supportsRename`/`supportsDelete` are always supported for agent-host - * sessions. + * Capabilities combine provider-local policy with the connection's live root + * state. Advertised agent capabilities re-emit after hydration; provider-local + * capabilities remain available independently of root-state availability. + * `supportsRename`/`supportsDelete` are always supported. */ readonly capabilities: IObservable; @@ -1642,9 +1625,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return { supportsRemoveArtifacts: !!connection?.removeSessionArtifact && supportsAgentHostArtifactRemoval(connection.initializeResult.read(reader)), supportsImport: this.isExternal.read(reader) && !!connection?.importSession && supportsAgentHostSessionImport(connection.initializeResult.read(reader)), - supportsCanvases: this.providerId === LOCAL_AGENT_HOST_PROVIDER_ID - && this.agentProvider === CopilotCLISessionType.id - && connection?.canvases !== undefined, + supportsCanvases: this._options.supportsCanvasPresentation?.(this.agentProvider) === true, supportsMultipleChats: !this.isQuickChat.read(reader) && (agentCapabilities?.multipleChats !== undefined), supportsFork: agentCapabilities?.multipleChats?.fork ?? false, supportsSideChat: agentCapabilities?.multipleChats?.sideChat ?? false, @@ -2591,16 +2572,34 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { }); } - private _createChatCanvasesObservable(chatUriOrObservable: URI | IObservable): IObservable { + private _createChatCanvasesObservable(chatUriOrObservable: URI | IObservable): IObservable { const chatUriObs = URI.isUri(chatUriOrObservable) ? constObservable(chatUriOrObservable) : chatUriOrObservable; + const chatStateObs = createActiveSessionSubscriptionObs( + this._options, + this.isActiveSessionObs, + StateComponents.Chat, + chatUriObs, + ); + const canvases = derivedOpts({ equalsFn: structuralEquals }, reader => { + const chatState = chatStateObs.read(reader).read(reader); + return !chatState || chatState instanceof Error ? undefined : chatState.canvases ?? []; + }); + const canvasStates = mapObservableArrayCached(this, canvases.map(references => references ?? []), canvas => { + const resource = URI.parse(canvas.resource); + const stateObs = createActiveSessionSubscriptionObs( + this._options, this.isActiveSessionObs, StateComponents.Canvas, constObservable(resource), + ); + return derived(reader => { + const state = stateObs.read(reader).read(reader); + return this._options.instantiationService.createInstance(AgentHostSessionCanvas, resource, + state && !(state instanceof Error) ? state : undefined); + }); + }, canvas => canvas.resource); return derived(reader => { - const chatUri = chatUriObs.read(reader); - const canvases = chatUri ? this._options.canvasSnapshots?.read(reader).get(chatUri.toString()) ?? [] : []; - return chatUri ? canvases.map(canvas => new AgentHostSessionCanvas( - chatUri, - canvas, - this._options.getConnection, - )) : []; + if (canvases.read(reader) === undefined) { + return undefined; + } + return canvasStates.read(reader).map(canvas => canvas.read(reader)); }); } @@ -3733,7 +3732,6 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ protected readonly _sessionStateSubscriptions = this._register(new DisposableMap()); protected readonly _connectionChanged = observableSignal(this); - private readonly _canvasSnapshots = observableValue>(this, new Map()); private readonly _chatCatalogLoading = new Map>(); private readonly _agentMergeSessionStateSubscriptions = this._register(new DisposableMap()); private readonly _agentMergeSessionStateIdleTimers = this._register(new DisposableMap()); @@ -3895,7 +3893,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * the bits that are uniform across hosts (`icon`, `loading`, * `mapDiffUri`) from the corresponding hooks. */ - protected abstract _adapterOptions(): Pick; + protected abstract _adapterOptions(): Pick; /** * Hook to normalize a session's metadata before it is cached, keyed, or @@ -3954,7 +3952,6 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement }, getBackendChatResource: chat => this.getBackendChatResource(chat), agentCapabilities: this._agentCapabilities, - canvasSnapshots: this._canvasSnapshots, backendSessionScheme: this._backendSessionScheme(provider), mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), connectionStatus: this.remoteConnectionStatus, @@ -4547,7 +4544,6 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement }, getBackendChatResource: chat => this.getBackendChatResource(chat), agentCapabilities: this._agentCapabilities, - canvasSnapshots: this._canvasSnapshots, mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), connectionStatus: this.remoteConnectionStatus, ...this._adapterOptions(), @@ -7563,28 +7559,6 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - // -- AHP notification / action handlers ---------------------------------- - - private _replaceCanvasSnapshots(snapshots: readonly IAgentCanvasSnapshot[]): void { - const next = new Map(); - for (const snapshot of snapshots) { - if (snapshot.canvases.length > 0) { - next.set(snapshot.chat.toString(), snapshot.canvases); - } - } - this._canvasSnapshots.set(next, undefined); - } - - private _updateCanvasSnapshot(chat: URI, canvases: readonly IAgentCanvas[]): void { - const next = new Map(this._canvasSnapshots.get()); - if (canvases.length > 0) { - next.set(chat.toString(), canvases); - } else { - next.delete(chat.toString()); - } - this._canvasSnapshots.set(next, undefined); - } - /** * Wire AHP notification and action listeners on the given connection. * Subclasses call this from their constructor (local) or `setConnection` @@ -7597,15 +7571,6 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._ensureSessionStateSubscription(sessionId); } - const canvasConnection = connection.canvases; - this._replaceCanvasSnapshots(canvasConnection?.getSnapshots() ?? []); - store.add(toDisposable(() => this._replaceCanvasSnapshots([]))); - if (canvasConnection) { - store.add(canvasConnection.onDidChange(snapshot => { - this._updateCanvasSnapshot(snapshot.chat, snapshot.canvases); - })); - } - store.add(connection.onDidNotification(n => { if (n.type === NotificationType.SessionAdded) { this._handleSessionAdded(n.summary); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 0a6179d1d46d33..f298b18d585de8 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -46,7 +46,7 @@ import { ISessionsService } from '../../../../services/sessions/browser/sessions import { ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; -import { AgentHostSessionAdapter } from './baseAgentHostSessionsProvider.js'; +import { AgentHostSessionAdapter, CopilotCLISessionType } from './baseAgentHostSessionsProvider.js'; import { DevContainerAgentHostSessionsProvider } from './devContainerAgentHostSessionsProvider.js'; import { ReconnectableAgentHostAutomationStore } from './reconnectableAgentHostAutomationStore.js'; @@ -318,6 +318,7 @@ export class LocalAgentHostSessionsProvider extends DevContainerAgentHostSession protected _adapterOptions() { return { + supportsCanvasPresentation: (agentProvider: string) => agentProvider === CopilotCLISessionType.id, buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectories: readonly URI[] | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined) => { const primary = workingDirectories?.[0]; const uriForDescription = project?.uri ?? primary; diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index f66053c2ad26ba..1f0a54e2c6890b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -19,10 +19,10 @@ import { extUriIgnorePathCase, isEqual } from '../../../../../../base/common/res import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { AgentCanvasAvailability, AgentSession, CODEX_AGENT_PROVIDER_ID, type IAgentCanvasSnapshot, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; +import { AgentSession, CODEX_AGENT_PROVIDER_ID, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; import { agentSdkSetupStatusKey } from '../../../../../../platform/agentHost/common/agentSdkSetup.js'; -import { AgentHostCodexAgentEnabledSettingId, IAgentHostService, type IAgentHostCanvases } from '../../../../../../platform/agentHost/common/agentService.js'; -import { getAgentHostExtensionInitializeResultMeta, supportsAgentHostCanvases } from '../../../../../../platform/agentHost/common/agentHostExtensionProtocol.js'; +import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { getAgentHostExtensionInitializeResultMeta } from '../../../../../../platform/agentHost/common/agentHostExtensionProtocol.js'; import { AgentHostAutonomousAutomationsCapabilityMetaKey } from '../../../../../../platform/agentHost/common/meta/agentHostAutomationsMeta.js'; import { CODEX_ACCOUNT_META_KEY } from '../../../../../../platform/agentHost/common/codexAccount.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -82,13 +82,14 @@ import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessi import { IPathService } from '../../../../../../workbench/services/path/common/pathService.js'; import { MockLabelService } from '../../../../../../workbench/services/label/test/common/mockLabelService.js'; import { TestPathService } from '../../../../../../workbench/test/browser/workbenchTestServices.js'; +import type { CanvasState } from '../../../../../../platform/agentHost/common/state/protocol/channels-canvas/state.js'; // ---- Mock IAgentHostService ------------------------------------------------- const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; const STORAGE_KEY_REMEMBERED_WORKSPACE_ISOLATIONS = 'sessions.agentHost.sessionConfigPicker.workspaceIsolations'; -type SubscriptionState = SessionState | ChangesetState | ChatState | AutomationState; +type SubscriptionState = SessionState | ChangesetState | ChatState | AutomationState | CanvasState; function createVSCodeSessionConfigSchema(overrides: SessionConfigSchema['properties'] = {}): SessionConfigSchema { return { @@ -136,23 +137,6 @@ class MockAgentHostService extends mock() { snapshots: [], _meta: { 'vscode.detachedWorktrees': true, [AgentHostAutonomousAutomationsCapabilityMetaKey]: true }, }); - private readonly _canvasSnapshots = new Map(); - private readonly _onDidChangeCanvases = new Emitter(); - private readonly _canvases: IAgentHostCanvases = { - onDidChange: this._onDidChangeCanvases.event, - getSnapshots: () => [...this._canvasSnapshots.values()], - resolveSource: async (chat, instanceId, revision) => { - const canvas = this._canvasSnapshots.get(chat.toString())?.canvases.find(canvas => canvas.instanceId === instanceId); - if (!canvas || canvas.revision !== revision || canvas.availability !== AgentCanvasAvailability.Ready) { - throw new Error('Canvas source is unavailable'); - } - return `https://example.test/${instanceId}/${revision}`; - }, - }; - override get canvases(): IAgentHostCanvases | undefined { - return supportsAgentHostCanvases(this.initializeResult.get()) ? this._canvases : undefined; - } - override readonly clientId = 'test-local-client'; private readonly _sessions = new Map(); public automationCatalog: AutomationState = { entries: [] }; @@ -209,15 +193,6 @@ class MockAgentHostService extends mock() { return this._nextSeq++; } - setCanvasSnapshot(snapshot: IAgentCanvasSnapshot): void { - if (snapshot.canvases.length > 0) { - this._canvasSnapshots.set(snapshot.chat.toString(), snapshot); - } else { - this._canvasSnapshots.delete(snapshot.chat.toString()); - } - this._onDidChangeCanvases.fire(snapshot); - } - /** * Number of upcoming `listSessions()` calls that should reject, used to * simulate the agent throwing `AHP_AUTH_REQUIRED` (or a transient offline @@ -390,7 +365,7 @@ class MockAgentHostService extends mock() { private readonly _sessionStateEmitters = new Map>(); private readonly _sessionStateErrorEmitters = new Map>(); - private readonly _sessionStateValues = new Map(); + private readonly _sessionStateValues = new Map(); public sessionSubscribeCounts = new Map(); public sessionUnsubscribeCounts = new Map(); @@ -417,8 +392,11 @@ class MockAgentHostService extends mock() { } const self = this; const sub: IAgentSubscription = { - get value() { return self._sessionStateValues.get(key) as unknown as T | undefined; }, - get verifiedValue() { return self._sessionStateValues.get(key) as unknown as T | undefined; }, + get value() { return self._sessionStateValues.get(key) as T | Error | undefined; }, + get verifiedValue() { + const value = self._sessionStateValues.get(key); + return value instanceof Error ? undefined : value as T | undefined; + }, onDidChange: emitter.event as unknown as Event, onDidError: errorEmitter.event, onWillApplyAction: Event.None, @@ -458,6 +436,15 @@ class MockAgentHostService extends mock() { this._sessionStateEmitters.get(chatUri)?.fire(state); } + setCanvasState(resource: string, state: CanvasState | Error): void { + this._sessionStateValues.set(resource, state); + if (state instanceof Error) { + this._sessionStateErrorEmitters.get(resource)?.fire(state); + return; + } + this._sessionStateEmitters.get(resource)?.fire(state); + } + setAutomationCatalog(state: AutomationState): void { this.automationCatalog = state; const key = URI.parse(AUTOMATION_CATALOG_URI).toString(); @@ -549,8 +536,6 @@ class MockAgentHostService extends mock() { this._onDidRootStateError.dispose(); this._onAgentHostStart.dispose(); this._onAgentHostExit.dispose(); - this._onDidChangeCanvases.dispose(); - this._canvasSnapshots.clear(); for (const emitter of this._sessionStateEmitters.values()) { emitter.dispose(); } @@ -1014,10 +999,6 @@ suite('LocalAgentHostSessionsProvider', () => { } const listenerCountAfterSessions = agentHost.rootStateListenerCount; - agentHost.initializeResult.set({ - ...agentHost.initializeResult.get(), - _meta: getAgentHostExtensionInitializeResultMeta(true, false, false, false, true), - }, undefined); agentHost.setRootState({ agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true } } } as AgentInfo], config: { schema: { type: 'object', properties: {} }, values: {} }, @@ -1045,6 +1026,26 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('enables canvas presentation only for local Copilot sessions', () => { + agentHost.setAgents([ + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: {} }, + { provider: CODEX_AGENT_PROVIDER_ID, displayName: 'Codex', description: '', models: [], capabilities: {} }, + ]); + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration(AgentHostCodexAgentEnabledSettingId, true); + const provider = createProvider(disposables, agentHost, undefined, { configurationService }); + fireSessionAdded(agentHost, 'canvas-copilot'); + fireSessionAdded(agentHost, 'canvas-codex', { provider: CODEX_AGENT_PROVIDER_ID }); + + assert.deepStrictEqual(provider.getSessions().map(session => ({ + sessionType: session.sessionType, + supportsCanvases: session.capabilities.get().supportsCanvases, + })), [ + { sessionType: 'copilotcli', supportsCanvases: true }, + { sessionType: CODEX_AGENT_PROVIDER_ID, supportsCanvases: false }, + ]); + }); + test('reports no session types before rootState hydrates', () => { agentHost.clearRootState(); const provider = createProvider(disposables, agentHost); @@ -1052,6 +1053,55 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(provider.sessionTypes, []); }); + test('projects advertised canvas channels through hydration, source updates, errors and removal', () => { + const activeSession = observableValue('activeSession', undefined); + agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: {} }]); + const provider = createProvider(disposables, agentHost, undefined, { activeSession }); + const rawId = 'canvas-owner'; + fireSessionAdded(agentHost, rawId); + const session = provider.getSessions()[0]; + const resource = 'ahp-canvas://another-host/documents/preview'; + const chat = 'ahp-chat:/canvas-owner'; + const summary: ChatSummary = { resource: chat, title: 'Main', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }; + agentHost.setSessionState(rawId, 'copilotcli', { + provider: 'copilotcli', title: 'Canvas owner', status: ProtocolSessionStatus.Idle, lifecycle: SessionLifecycle.Ready, + activeClients: [], chats: [summary], defaultChat: chat, + }); + const chatState: ChatState = { ...summary, turns: [], canvases: [{ resource }] }; + agentHost.setChatState(chat, chatState); + activeSession.set(upcastPartial({ ...session, activeChat: session.mainChat }), undefined); + disposables.add(autorun(reader => session.mainChat.read(reader).canvases?.read(reader))); + const read = () => session.mainChat.get().canvases?.get()?.map(canvas => ({ + resource: canvas.resource.toString(), instanceId: canvas.instanceId, source: canvas.source?.toString(), + })); + const pending = read(); + const canvas: CanvasState = { instanceId: 'preview', extensionId: 'example:provider', canvasId: 'preview', url: 'https://example.test/preview' }; + agentHost.setCanvasState(resource, canvas); + const ready = read(); + agentHost.setCanvasState(resource, { ...canvas, title: 'Updated', url: 'https://example.test/replaced' }); + const updated = read(); + agentHost.setCanvasState(resource, new Error('Channel unavailable')); + const unavailable = read(); + agentHost.setCanvasState(resource, { ...canvas, url: 'http:relative' }); + const unsupported = read(); + agentHost.setChatState(chat, { ...chatState, canvases: [] }); + + assert.deepStrictEqual({ + supportsCanvases: session.capabilities.get().supportsCanvases, + pending, ready, updated, unavailable, unsupported, removed: read(), + subscribes: agentHost.sessionSubscribeCounts.get(resource), + unsubscribes: agentHost.sessionUnsubscribeCounts.get(resource), + }, { + supportsCanvases: true, + pending: [{ resource, instanceId: undefined, source: undefined }], + ready: [{ resource, instanceId: 'preview', source: canvas.url }], + updated: [{ resource, instanceId: 'preview', source: 'https://example.test/replaced' }], + unavailable: [{ resource, instanceId: undefined, source: undefined }], + unsupported: [{ resource, instanceId: 'preview', source: undefined }], + removed: [], subscribes: 1, unsubscribes: 1, + }); + }); + test('rebinds session types when Agent Host starts with a new root subscription', () => runWithFakedTimers({ useFakeTimers: true }, async () => { agentHost.clearRootState(); const provider = createProvider(disposables, agentHost); @@ -3787,7 +3837,7 @@ suite('LocalAgentHostSessionsProvider', () => { await timeout(0); const session = provider.getSessions()[0]; - assert.deepStrictEqual(session?.capabilities.get(), { supportsRemoveArtifacts: false, supportsImport: false, supportsCanvases: false, supportsMultipleChats: false, supportsFork: true, supportsSideChat: false, supportsRename: true, supportsDelete: true }); + assert.deepStrictEqual(session?.capabilities.get(), { supportsRemoveArtifacts: false, supportsImport: false, supportsCanvases: true, supportsMultipleChats: false, supportsFork: true, supportsSideChat: false, supportsRename: true, supportsDelete: true }); })); test('restored quick chat collapses to a single chat even when state advertises peer chats', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -7338,68 +7388,6 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('maps local Copilot canvas snapshots into provider-neutral chat canvases', async () => { - const agents = [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: {} } as AgentInfo]; - agentHost.initializeResult.set({ - ...agentHost.initializeResult.get(), - _meta: getAgentHostExtensionInitializeResultMeta(true, false, false, false, true), - }, undefined); - agentHost.setRootState({ - agents, - config: { schema: { type: 'object', properties: {} }, values: {} }, - }); - const activeSession = observableValue('activeSession', undefined); - const provider = createProvider(disposables, agentHost, undefined, { activeSession }); - const rawId = 'canvas-projection'; - const session = setupMultiChatSession(provider, rawId); - activeSession.set(upcastPartial({ ...session, activeChat: session.mainChat }), undefined); - const sessionUri = AgentSession.uri('copilotcli', rawId); - const chat = URI.parse(buildDefaultChatUri(sessionUri)); - agentHost.setSessionState(rawId, 'copilotcli', makeState([ - makeChatSummary(chat.toString(), 'Default'), - ], { defaultChat: chat.toString() })); - agentHost.setCanvasSnapshot({ - chat, - canvases: [{ - instanceId: 'preview-1', - extensionId: 'project:preview', - extensionName: 'Preview', - canvasId: 'preview', - title: 'Preview', - status: 'ready', - revision: 2, - availability: AgentCanvasAvailability.Ready, - }], - }); - const canvas = session.mainChat.get().canvases?.get()[0]; - assert.ok(canvas); - const source = await canvas.resolveSource(); - - assert.deepStrictEqual({ - supportsCanvases: session.capabilities.get().supportsCanvases, - canvas: { - resource: canvas.resource.scheme, - instanceId: canvas.instanceId, - title: canvas.title, - status: canvas.status, - revision: canvas.revision, - availability: canvas.availability, - }, - source: source.toString(), - }, { - supportsCanvases: true, - canvas: { - resource: 'agent-host-canvas', - instanceId: 'preview-1', - title: 'Preview', - status: 'ready', - revision: 2, - availability: 'ready', - }, - source: 'https://example.test/preview-1/2', - }); - }); - test('nested chats default to their own Session Changes while the main chat keeps Branch Changes', () => { const activeSession = observableValue('test.activeSession', undefined); const provider = createProvider(disposables, agentHost, undefined, { activeSession }); diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 14efe94f09b91e..c3eccf170be3a2 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -634,28 +634,18 @@ export interface IChatCapabilities { /** Capabilities assumed for a chat that does not advertise its own. */ export const DEFAULT_CHAT_CAPABILITIES: IChatCapabilities = { canRename: true, canArchive: false, canDelete: true }; -/** Availability of a live canvas source. */ -export const enum SessionCanvasAvailability { - Ready = 'ready', - Unavailable = 'unavailable', -} - /** A model-opened canvas owned by one chat. */ export interface ISessionCanvas { /** Stable canvas identity within its owning chat. */ readonly resource: URI; - /** Stable provider-supplied instance identifier. */ - readonly instanceId: string; + /** Stable provider-supplied instance identifier; absent before canvas state hydrates. */ + readonly instanceId: string | undefined; /** Display title. */ readonly title: string; /** Optional provider status text. */ readonly status?: string; - /** Monotonic instance revision. */ - readonly revision: number; - /** Whether the current source can be resolved. */ - readonly availability: SessionCanvasAvailability; - /** Resolve the current HTTP(S) source for this revision. */ - resolveSource(): Promise; + /** Current live HTTP(S) source; absent while the provider is unavailable. */ + readonly source: URI | undefined; } /** @@ -718,8 +708,8 @@ export interface IChat { * output stream. Providers that cannot determine this omit the observable. */ readonly customizations?: IObservable; - /** Live model-opened canvases owned by this chat. */ - readonly canvases?: IObservable; + /** Server-published live canvases owned by this chat; undefined while membership is unknown. */ + readonly canvases?: IObservable; /** Active background shells, including commands started in earlier turns. */ readonly backgroundShells?: IObservable; /** Checkpoints associated with the chat. */ @@ -938,7 +928,7 @@ export interface ISessionCapabilities { readonly supportsImport?: boolean; /** Whether recorded artifacts can be removed from this session. */ readonly supportsRemoveArtifacts?: boolean; - /** Whether this session can expose model-opened canvases. */ + /** Whether the owning provider permits presenting server-published canvases for this session. */ readonly supportsCanvases?: boolean; /** Whether this session supports multiple chats. */ readonly supportsMultipleChats: boolean; From a1aae94b265d3401280700f921544a124934eacb Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Sat, 3 Oct 2026 00:50:28 +0200 Subject: [PATCH 2/2] agent host: test: update chat summary expectations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/test/node/agentHostStateManager.test.ts | 4 ++-- .../agentHost/test/node/protocolServerHandler.test.ts | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 4fb49ffe86137f..e28ef1d332181f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -1248,8 +1248,8 @@ suite('AgentHostStateManager', () => { addedTitle: 'Peer', chatResources: [buildDefaultChatUri(sessionUri), peerChat].sort(), summaryChats: [ - { resource: buildDefaultChatUri(sessionUri), title: 'Test', origin: { kind: MessageKind.User } }, - { resource: peerChat, title: 'Peer', origin: { kind: MessageKind.User } }, + { resource: buildDefaultChatUri(sessionUri), title: 'Test', origin: { kind: MessageKind.User }, status: SessionStatus.Idle }, + { resource: peerChat, title: 'Peer', origin: { kind: MessageKind.User }, status: SessionStatus.Idle }, ], defaultChat: buildDefaultChatUri(sessionUri), peerTurns: 0, diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 43f8ece5705cc9..d38c52524469fe 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -2121,7 +2121,16 @@ suite('ProtocolServerHandler', () => { .map(message => (message.params as SessionSummaryChangedParams).changes); assert.deepStrictEqual({ listedMeta, summaryChanges }, { listedMeta: { providerOnly: true, live: 'current' }, - summaryChanges: [{ modifiedAt: startedAt, status: SessionStatus.InProgress }], + summaryChanges: [{ + chats: [{ + resource: defaultChatUri, + title: '', + origin: { kind: MessageKind.User }, + status: SessionStatus.InProgress, + }], + modifiedAt: startedAt, + status: SessionStatus.InProgress, + }], }); }); });