From d4ce3e6f4c66be8b099db97b7c41e46037fa90f4 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Sat, 15 Aug 2026 22:49:33 -0700 Subject: [PATCH 1/4] agentHost: route MCP apps through exact chats (#331055) Route MCP App side-channel requests through concrete Agent Host chat URIs instead of provider SDK conversation IDs. - Give MCP customization controllers an immutable AHP chat URI and derive their session and provider identity from it. - Route Copilot and Codex MCP requests to the exact bound chat. - Pass chat routing identity into restored Copilot tool metadata. - Add focused coverage for channel parsing, provider routing, and history restoration. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/agent.ts | 4 +- .../platform/agentHost/node/agentService.ts | 3 +- .../agentHost/node/codex/codexAgent.ts | 34 +++++--- .../agentHost/node/copilot/copilotAgent.ts | 13 ++- .../node/copilot/copilotAgentSession.ts | 12 +-- .../node/copilot/mapSessionEvents.ts | 26 ++++-- .../node/shared/mcpCustomizationController.ts | 86 +++++++++++++------ .../test/node/buildSessionEvents.test.ts | 9 +- .../test/node/codex/codexAgent.test.ts | 13 ++- .../agentHost/test/node/copilotAgent.test.ts | 20 ++--- .../test/node/copilotAgentSession.test.ts | 5 +- .../test/node/historyRecordFixtures.test.ts | 8 +- .../test/node/mapSessionEvents.perf.test.ts | 5 +- .../test/node/mapSessionEvents.test.ts | 16 ++-- .../shared/mcpCustomizationController.test.ts | 43 ++++++---- 15 files changed, 184 insertions(+), 113 deletions(-) diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index cc5ee6b72368c7..b895265c1c06df 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1145,8 +1145,8 @@ export interface IAgent { /** Optional lifecycle operation paired with {@link startMcpServer}. */ stopMcpServer?(session: URI, id: string): Promise; - /** Optional `mcp://` router for providers that advertise MCP side-channel resources. */ - handleMcpRequest?(session: URI, serverName: string, method: string, params: Record | undefined): Promise; + /** Optional `mcp://` router for providers that advertise chat-scoped MCP side-channel resources. */ + handleMcpRequest?(chat: URI, serverName: string, method: string, params: Record | undefined): Promise; /** Optional notification stream paired with {@link handleMcpRequest}. */ readonly onMcpNotification?: Event; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 358049a9e71e1d..b3c9e9e5c77320 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -959,8 +959,7 @@ export class AgentService extends Disposable implements IAgentService { if (!provider || !provider.handleMcpRequest) { throw new Error(`Method not found: no provider for mcp:// channel ${channel}`); } - const sessionUri = AgentSession.uri(route.providerId, route.sessionId); - return provider.handleMcpRequest(sessionUri, route.serverName, method, params); + return provider.handleMcpRequest(route.chatUri, route.serverName, method, params); } // ---- session management ------------------------------------------------- diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index d25d334bb11f8e..99aef2aa2e5197 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -5636,8 +5636,10 @@ export class CodexAgent extends Disposable implements IAgent { return []; } const controller = this._getOrCreateMcpController(session); - controller.applyAll(inventoryToSdkServers(this._mcpInventory)); - this._refreshMcpCustomizationIds(session, controller); + controller?.applyAll(inventoryToSdkServers(this._mcpInventory)); + if (controller) { + this._refreshMcpCustomizationIds(session, controller); + } const [workspaceAgents, skillHookContainers] = await Promise.all([ discoverCodexWorkspaceAgents(this._workingDirectories(session), this._fileService), this._fetchSkillHookContainers(session), @@ -5648,7 +5650,7 @@ export class CodexAgent extends Disposable implements IAgent { return [ ...workspaceAgents.containers, ...session.clientCustomizations.toCustomizations(), - ...controller.topLevelCustomizations(), + ...(controller?.topLevelCustomizations() ?? []), ...skillHookContainers, ]; } @@ -5709,11 +5711,14 @@ export class CodexAgent extends Disposable implements IAgent { * `Method not found` so the protocol server maps them to JSON-RPC * `-32601`. */ - async handleMcpRequest(sessionUri: URI, serverName: string, method: string, params: Record | undefined): Promise { - const sessionId = AgentSession.id(sessionUri); + async handleMcpRequest(chat: URI, serverName: string, method: string, params: Record | undefined): Promise { + const sessionId = this._sessionIdByChatUri.get(chat.toString()); + if (!sessionId) { + throw new Error(`Method not found: no active chat ${chat.toString()}`); + } const session = this._sessions.get(sessionId); - if (!session) { - throw new Error(`Method not found: no active session ${sessionId}`); + if (!session || !session.chatChannel || !isEqual(session.chatChannel, chat)) { + throw new Error(`Method not found: no active chat ${chat.toString()}`); } const entry = this._mcpInventory.get(serverName); if (!entry) { @@ -5780,6 +5785,9 @@ export class CodexAgent extends Disposable implements IAgent { private _resolveMcpServerName(session: ICodexSession, id: string): string | undefined { const controller = this._getOrCreateMcpController(session); + if (!controller) { + return undefined; + } controller.applyAll(inventoryToSdkServers(this._mcpInventory)); this._refreshMcpCustomizationIds(session, controller); return controller.serverNameForCustomizationId(id); @@ -5790,12 +5798,13 @@ export class CodexAgent extends Disposable implements IAgent { * registered on the agent (sessions come and go) — disposed explicitly * when the session is removed. */ - private _getOrCreateMcpController(session: ICodexSession): McpCustomizationController { + private _getOrCreateMcpController(session: ICodexSession): McpCustomizationController | undefined { + if (!session.chatChannel) { + return undefined; + } if (!session.mcpController) { session.mcpController = this._instantiationService.createInstance(McpCustomizationController, { - providerId: this.id, - sessionId: session.sessionId, - sessionUri: session.sessionUri, + chatUri: session.chatChannel, emit: action => this._fire(session.sessionUri, action), capabilities: CODEX_MCP_APP_CAPABILITIES, pluginMcpServerSources: () => codexPluginMcpServerSources(session.clientCustomizations.plugins()), @@ -5816,6 +5825,9 @@ export class CodexAgent extends Disposable implements IAgent { continue; } const controller = this._getOrCreateMcpController(session); + if (!controller) { + continue; + } controller.applyAll(servers); this._refreshMcpCustomizationIds(session, controller); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 5db3c597357070..58356715d2baa2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1283,10 +1283,10 @@ export class CopilotAgent extends Disposable implements IAgent { return applyMcpServerEnablement(customizations, this._retainedHostCustomizations(session)); } - async handleMcpRequest(session: URI, serverName: string, method: string, params: Record | undefined): Promise { - const entry = this._findSessionChat(session); + async handleMcpRequest(chat: URI, serverName: string, method: string, params: Record | undefined): Promise { + const entry = this._findChatByUri(chat); if (!entry) { - throw new Error(`Method not found: no active session ${AgentSession.id(session)}`); + throw new Error(`Method not found: no active chat ${chat.toString()}`); } return entry.handleMcpRequest(serverName, method, params); } @@ -2925,7 +2925,7 @@ export class CopilotAgent extends Disposable implements IAgent { freeLongContext: this._isFreeLongContext(provisional.model?.id), workspaceless: provisional.workspaceless, }; - const chatChannelUri = this._findBoundSessionChatUri(sdkSessionId) ?? sessionUri; + const chatChannelUri = this._findBoundSessionChatUri(sdkSessionId) ?? URI.parse(buildDefaultChatUri(sessionUri)); agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient, { sessionUri, chatChannelUri, @@ -4134,7 +4134,7 @@ export class CopilotAgent extends Disposable implements IAgent { /** Instantiates a session; the caller must initialize and register it on success. */ private _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: ActiveClient, identity?: ICopilotAgentSessionIdentity): CopilotAgentSession { const sessionUri = identity?.sessionUri ?? AgentSession.uri(this.id, launchPlan.sessionId); - const chatChannelUri = identity?.chatChannelUri ?? this._findBoundSessionChatUri(launchPlan.sessionId) ?? sessionUri; + const chatChannelUri = identity?.chatChannelUri ?? this._findBoundSessionChatUri(launchPlan.sessionId) ?? URI.parse(buildDefaultChatUri(sessionUri)); const agentSession = this._instantiationService.createInstance( CopilotAgentSession, @@ -4151,7 +4151,7 @@ export class CopilotAgent extends Disposable implements IAgent { customizationDirectory, clientSnapshot: launchPlan.snapshot, activeClientToolSet: launchPlan.activeClientToolSet, - // Evaluate membership against the session's current chat channel; `bindChatChannel` can move it later. + // Evaluate membership against the session's chat channel. clientReachesChat: (clientId, chat) => activeClient.contributesTo(clientId, chat.toString()), // MCP reconcile has no host call of its own, so read the retained host snapshot lazily. hostCustomizations: () => this._retainedHostCustomizations(sessionUri), @@ -4211,7 +4211,6 @@ export class CopilotAgent extends Disposable implements IAgent { this._throwIfClientReplaced(client, agentSession); const boundChat = this._findBoundSessionChatUri(sessionId); if (boundChat) { - agentSession.bindChatChannel?.(boundChat); this._registerLiveChat(boundChat, agentSession, activeClient); return; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index e897e99f70c699..2b18bec0477eb1 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -625,7 +625,7 @@ export class CopilotAgentSession extends Disposable { get ownerSessionUri(): URI { return this._ownerSessionUri; } /** @deprecated Compatibility alias for SDK callbacks; this is the exact persistence resource. */ get sessionUri(): URI { return this.resourceUri; } - private _chatChannelUri: URI; + private readonly _chatChannelUri: URI; /** Fixed persistence scope for this chat; never re-derived from the mutable routing channel. Config reads/writes must use {@link _ownerSessionUri} instead — peer chats share that scope but have distinct storage. */ private readonly _storageUri: URI; @@ -633,10 +633,6 @@ export class CopilotAgentSession extends Disposable { return this._chatChannelUri; } - bindChatChannel(chatChannelUri: URI): void { - this._chatChannelUri = chatChannelUri; - } - /** Working directory this session operates in, if any. */ get workingDirectory(): URI | undefined { return this._workingDirectory; } @@ -954,9 +950,7 @@ export class CopilotAgentSession extends Disposable { return sourceUri === undefined ? [] : plugin.mcpServers.map(server => [server.name, sourceUri.toString()] as const); })); this._mcpCustomizations = this._register(this._instantiationService.createInstance(McpCustomizationController, { - providerId: this.resourceUri.scheme, - sessionId: this.sessionId, - sessionUri: this.resourceUri, + chatUri: this._chatChannelUri, emit: action => this._emitAction(action), pluginMcpServerSources: () => pluginMcpServerSources, resolveEnablement: (server, owningPluginUri) => { @@ -2440,7 +2434,7 @@ export class CopilotAgentSession extends Disposable { } catch { // Database may not exist yet — that's fine } - const result = await mapSessionEvents(this._storageUri, db, events, { + const result = await mapSessionEvents(this._storageUri, db, events, this._chatChannelUri, { workingDirectory: this._workingDirectory, model: this._launchPlan.kind === 'create' ? this._launchPlan.model diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index f621f9e9a1e134..282e3d80a51d9f 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -15,7 +15,7 @@ import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { toToolCallMeta, type IToolCallUiMeta, type ToolKind } from '../../common/meta/agentToolCallMeta.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; -import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type AgentSelection, type ErrorInfo, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type TerminalCommandResult, type ToolCallCompletedState, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseChatUri, type AgentSelection, type ErrorInfo, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type TerminalCommandResult, type ToolCallCompletedState, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { buildNonPtyShellTerminalUri } from './copilotNonPtyShellTerminals.js'; import { getInvocationMessage, getPastTenseMessage, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; import { buildSessionDbUri } from '../../common/sessionDbUri.js'; @@ -310,11 +310,16 @@ export async function mapSessionEvents( session: URI, db: ISessionDatabase | undefined, events: readonly SessionEvent[], - options: URI | IMapSessionEventsOptions | undefined = undefined, + routingChatUri: URI, + options: IMapSessionEventsOptions | undefined = undefined, ): Promise<{ turns: Turn[]; subagentTurnsByToolCallId: ReadonlyMap }> { - const workingDirectory = options instanceof URI ? options : options?.workingDirectory; - let currentModel = options instanceof URI ? undefined : options?.model; - let currentAgent = options instanceof URI ? undefined : options?.agent; + const routingChat = parseChatUri(routingChatUri); + if (!routingChat) { + throw new Error(`Malformed AHP chat URI: ${routingChatUri.toString()}`); + } + const workingDirectory = options?.workingDirectory; + let currentModel = options?.model; + let currentAgent = options?.agent; // First pass: collect tool-arg info and identify edit tool calls so we // can batch-load their stored file edits before the second pass needs // them at `tool.execution_complete` time. We also build the @@ -387,8 +392,9 @@ export async function mapSessionEvents( } const sessionUriStr = session.toString(); - const providerId = session.scheme; - const rawSessionId = AgentSession.id(session); + const routingSession = URI.parse(routingChat.session); + const providerId = routingSession.scheme; + const rawSessionId = AgentSession.id(routingSession); const turns: Turn[] = []; // Subagent state. Each subagent has its own active turn builder; only @@ -664,7 +670,7 @@ export async function mapSessionEvents( // No active turn to attach this completion to. continue; } - const completedPart = makeCompletedToolCallPart(d, info, sessionUriStr, providerId, rawSessionId, storedEdits, subagentInfoByToolCallId.get(d.toolCallId), workingDirectory); + const completedPart = makeCompletedToolCallPart(d, info, sessionUriStr, providerId, rawSessionId, routingChatUri, storedEdits, subagentInfoByToolCallId.get(d.toolCallId), workingDirectory); builder.responseParts.push(completedPart); // When a parent tool call that spawned a subagent completes, // flush the subagent's accumulated turn. @@ -755,6 +761,7 @@ export async function mapSessionEvents( sessionUriStr, providerId, rawSessionId, + routingChatUri, storedEdits, subagentInfoByToolCallId.get(request.toolCallId), workingDirectory, @@ -858,6 +865,7 @@ function makeCompletedToolCallPart( sessionUriStr: string, providerId: string, rawSessionId: string, + chatURI: URI, storedEdits: Map | undefined, subagent: ISubagentInfo | undefined, workingDirectory: URI | undefined, @@ -916,7 +924,7 @@ function makeCompletedToolCallPart( const mcpUi: IToolCallUiMeta | undefined = mcpUiResourceUri ? { resourceUri: mcpUiResourceUri, - ...(mcpServerName ? { channel: buildMcpChannel(providerId, rawSessionId, mcpServerName) } : {}), + ...(mcpServerName ? { channel: buildMcpChannel(chatURI, mcpServerName) } : {}), } : undefined; diff --git a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts index e4e4984c446608..1798ba5d2134ea 100644 --- a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts +++ b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts @@ -6,10 +6,12 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { derived, observableValue, transaction, type IObservable, type ITransaction } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; +import { AgentSession } from '../../common/agent.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; import { CustomizationType, McpServerStatus, type AhpMcpUiHostCapabilities, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; import { DEFAULT_MCP_APP, DEFAULT_MCP_APP_CAPABILITIES } from '../../common/state/protocol/mcpAppDefaults.js'; +import { parseChatUri } from '../../common/state/sessionState.js'; import type { SessionAction } from '../../common/state/sessionActions.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js'; @@ -49,12 +51,8 @@ export { DEFAULT_MCP_APP_CAPABILITIES, DEFAULT_MCP_APP }; * Options for {@link McpCustomizationController}. */ export interface IMcpCustomizationControllerOptions { - /** Provider id (e.g. `'copilotcli'`). Used as the channel URI authority. */ - readonly providerId: string; - /** Session id (the raw id, not the full URI). Used as the channel path segment. */ - readonly sessionId: string; - /** Canonical session URI used to resolve persisted customization state. */ - readonly sessionUri: URI; + /** Concrete chat URI used for MCP App routing. */ + readonly chatUri: URI; /** Emits a {@link SessionAction} into the session's action stream. */ readonly emit: (action: SessionAction) => void; /** Returns durable plugin source URIs for plugin-provided MCP servers. */ @@ -80,8 +78,9 @@ export function buildMcpTopLevelCustomizationId(providerId: string, sessionId: s return `mcp-top-level:${providerId}:${sessionId}:${serverName}`; } -export function buildMcpChannel(providerId: string, sessionId: string, serverName: string): string { - return `mcp://${providerId}/${encodeURIComponent(sessionId)}/${encodeURIComponent(serverName)}`; +export function buildMcpChannel(chatUri: URI, serverName: string): string { + const providerId = getMcpChannelProviderId(chatUri); + return `mcp://${providerId}/${encodeURIComponent(chatUri.toString())}/${encodeURIComponent(serverName)}`; } /** @@ -109,6 +108,11 @@ export function buildMcpChannel(providerId: string, sessionId: string, serverNam */ export class McpCustomizationController extends Disposable { + private readonly _chatUri: URI; + private readonly _providerId: string; + private readonly _sessionId: string; + private readonly _sessionUri: URI; + /** Per-server live entries, keyed by server name. */ private readonly _live = observableValue>(this, new Map()); @@ -128,6 +132,17 @@ export class McpCustomizationController extends Disposable { @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, ) { super(); + this._chatUri = this._options.chatUri; + const chat = parseChatUri(this._chatUri); + if (!chat) { + throw new Error(`Malformed AHP chat URI: ${this._chatUri.toString()}`); + } + this._sessionUri = URI.parse(chat.session); + this._providerId = AgentSession.provider(this._sessionUri) ?? ''; + this._sessionId = AgentSession.id(this._sessionUri); + if (!this._providerId || !this._sessionId) { + throw new Error(`Malformed Agent Host session URI: ${chat.session}`); + } this.runtimeStates = derived(this, reader => { const out = new Map(); for (const entry of this._live.read(reader).values()) { @@ -365,7 +380,7 @@ export class McpCustomizationController extends Disposable { } private _mintTopLevelId(serverName: string): string { - return buildMcpTopLevelCustomizationId(this._options.providerId, this._options.sessionId, serverName); + return buildMcpTopLevelCustomizationId(this._providerId, this._sessionId, serverName); } private _resolveChildId(serverName: string): string | undefined { @@ -373,7 +388,7 @@ export class McpCustomizationController extends Disposable { } private _findPublishedMcpCustomization(serverName: string): { readonly topLevelId?: string; readonly childId?: string } | undefined { - const customizations = this._stateManager.getSessionState(this._options.sessionUri.toString())?.customizations ?? []; + const customizations = this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? []; const topLevel = customizations.find(customization => customization.type === CustomizationType.McpServer && customization.name === serverName); if (topLevel?.type === CustomizationType.McpServer) { return { topLevelId: topLevel.id }; @@ -386,7 +401,7 @@ export class McpCustomizationController extends Disposable { if (state.kind !== McpServerStatus.Ready) { return undefined; } - return buildMcpChannel(this._options.providerId, this._options.sessionId, serverName); + return buildMcpChannel(this._chatUri, serverName); } private _buildTopLevel(id: string, serverName: string, state: McpServerState, enabled: boolean): McpServerCustomization { @@ -400,7 +415,7 @@ export class McpCustomizationController extends Disposable { const mcpApp = this._options.capabilities ? { capabilities: this._options.capabilities } : DEFAULT_MCP_APP; - const existing = getMcpServerCustomizations(this._stateManager.getSessionState(this._options.sessionUri.toString())?.customizations ?? []) + const existing = getMcpServerCustomizations(this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? []) .find(customization => customization.id === id); const customization: McpServerCustomization = { type: CustomizationType.McpServer, @@ -504,22 +519,32 @@ export function findMcpServerName(customizations: readonly Customization[], id: } /** - * Parsed `mcp:////` URI as minted by + * Parsed `mcp:////` URI as minted by * {@link McpCustomizationController}. The path segments are * URL-decoded. */ export interface IMcpChannelRoute { readonly providerId: string; - readonly sessionId: string; + readonly chatUri: URI; readonly serverName: string; } +function getMcpChannelProviderId(chatUri: URI): string { + const chat = parseChatUri(chatUri); + if (!chat) { + throw new Error(`Malformed AHP chat URI: ${chatUri.toString()}`); + } + const providerId = AgentSession.provider(chat.session); + if (!providerId) { + throw new Error(`Malformed Agent Host session URI: ${chat.session}`); + } + return providerId; +} + /** * Decodes a channel URI string into a {@link IMcpChannelRoute}, or * returns `undefined` when the URI is not an `mcp://` channel or the - * path is malformed. Intentionally uses string parsing rather than - * `URI.parse` so the helper stays usable from layers (e.g. agentService - * test fixtures) without a full URI dependency. + * path is malformed. */ export function parseMcpChannelUri(uri: string): IMcpChannelRoute | undefined { const prefix = 'mcp://'; @@ -533,24 +558,29 @@ export function parseMcpChannelUri(uri: string): IMcpChannelRoute | undefined { } const providerId = rest.slice(0, slash); const tail = rest.slice(slash + 1); - const sep = tail.indexOf('/'); - if (sep <= 0 || sep === tail.length - 1) { + const segments = tail.split('/'); + if (segments.length !== 2 || !segments[0] || !segments[1]) { return undefined; } - let sessionId: string; + let chatUri: URI; let serverName: string; try { - // `decodeURIComponent` throws `URIError` on malformed percent - // escapes (e.g. a lone `%`). Treat any decode failure as a - // malformed channel rather than letting it escape — the caller - // translates `undefined` into a clean `Method not found`. - sessionId = decodeURIComponent(tail.slice(0, sep)); - serverName = decodeURIComponent(tail.slice(sep + 1)); + chatUri = URI.parse(decodeURIComponent(segments[0])); + serverName = decodeURIComponent(segments[1]); + } catch { + return undefined; + } + if (!providerId || !serverName) { + return undefined; + } + let routedProviderId: string; + try { + routedProviderId = getMcpChannelProviderId(chatUri); } catch { return undefined; } - if (!providerId || !sessionId || !serverName) { + if (routedProviderId !== providerId) { return undefined; } - return { providerId, sessionId, serverName }; + return { providerId, chatUri, serverName }; } diff --git a/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts index e14d254eeb668f..889001cde9056a 100644 --- a/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts @@ -5,11 +5,12 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { URI } from '../../../../base/common/uri.js'; import { generateUuid, isUUID } from '../../../../base/common/uuid.js'; import { AgentSession } from '../../common/agent.js'; -import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type ToolCallCompletedState, type Turn } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, type ResponsePart, type ToolCallCompletedState, type Turn } from '../../common/state/sessionState.js'; import { buildSessionEventLogFromTurns, buildSessionEventsFromTurns, serializeSessionEventsToJsonl } from '../../node/copilot/buildSessionEvents.js'; -import { mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; +import { mapSessionEvents as mapSessionEventsWithRouting } from '../../node/copilot/mapSessionEvents.js'; import type { SessionEvent } from '@github/copilot-sdk'; suite('buildSessionEventsFromTurns — reverse of mapSessionEvents', () => { @@ -19,6 +20,10 @@ suite('buildSessionEventsFromTurns — reverse of mapSessionEvents', () => { const session = AgentSession.uri('copilot', 'test-session'); const sessionId = 'test-session'; + function mapSessionEvents(session: URI, db: undefined, events: Parameters[2]) { + return mapSessionEventsWithRouting(session, db, events, URI.parse(buildChatUri(session, 'default'))); + } + function markdown(content: string): ResponsePart { return { kind: ResponsePartKind.Markdown, id: 'ignored', content }; } diff --git a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts index 2d1430b7ac8f61..d4d94d7123846e 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts @@ -35,6 +35,7 @@ interface ICodexConversationResolverHarness { interface ICodexMcpControllerSession { readonly sessionId: string; readonly sessionUri: URI; + chatChannel: URI | undefined; readonly clientCustomizations: CodexClientCustomizationStore; mcpController: McpCustomizationController | undefined; } @@ -57,9 +58,9 @@ function resolveConversationSession(harness: ICodexConversationResolverHarness, return resolver.call(harness, address, context); } -function getOrCreateMcpController(harness: ICodexMcpControllerHarness, session: ICodexMcpControllerSession): McpCustomizationController { +function getOrCreateMcpController(harness: ICodexMcpControllerHarness, session: ICodexMcpControllerSession): McpCustomizationController | undefined { const getOrCreate = (CodexAgent.prototype as unknown as { - _getOrCreateMcpController(this: ICodexMcpControllerHarness, session: ICodexMcpControllerSession): McpCustomizationController; + _getOrCreateMcpController(this: ICodexMcpControllerHarness, session: ICodexMcpControllerSession): McpCustomizationController | undefined; })._getOrCreateMcpController; return getOrCreate.call(harness, session); } @@ -117,13 +118,14 @@ suite('CodexAgent', () => { }); }); - test('keeps fresh plugin MCP ownership after client customization resyncs, including disabled plugins', () => { + test('creates MCP customization state only after a concrete chat is bound', () => { const store = new DisposableStore(); const stateManager = store.add(new AgentHostStateManager(new NullLogService())); const customizations = new CodexClientCustomizationStore(); const session: ICodexMcpControllerSession = { sessionId: 'session-1', sessionUri: AgentSession.uri('codex', 'session-1'), + chatChannel: undefined, clientCustomizations: customizations, mcpController: undefined, }; @@ -145,7 +147,10 @@ suite('CodexAgent', () => { }, _fire: () => { }, }; + const beforeChatBinding = getOrCreateMcpController(harness, session); + session.chatChannel = URI.parse(buildDefaultChatUri(session.sessionUri)); const controller = getOrCreateMcpController(harness, session); + assert.ok(controller); const plugin = { synced: { customization: { id: 'azure-plugin', uri: pluginUri } }, parsed: { mcpServers: [{ name: 'azure' }] }, @@ -171,11 +176,13 @@ suite('CodexAgent', () => { const topLevelEnablement = controller.topLevelCustomizations()[0]?.enablement; assert.deepStrictEqual({ + beforeChatBinding, owner, topLevelKey: getCustomizationEnablementKey(targetForMcpServer(topLevel, owner, false), CustomizationEnablementKind.Global), nestedKey: getCustomizationEnablementKey(targetForMcpServer(nested, pluginUri, false), CustomizationEnablementKind.Global), topLevelEnablement, }, { + beforeChatBinding: undefined, owner: pluginUri, topLevelKey: `${pluginUri}#mcp=azure`, nestedKey: `${pluginUri}#mcp=azure`, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 532fde1ea8e410..5aeb1c57c6e7f8 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -6808,7 +6808,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual({ mcpCalls, - mcpRequest: await agent.handleMcpRequest(session, 'srv', 'tools/list', undefined), + mcpRequest: await agent.handleMcpRequest(defaultChatUri(session), 'srv', 'tools/list', undefined), customizations: await getDefaultChatCustomizations(agent, session), // Constructing the agent never touched the state manager. sessions: stateManager.getSessionUris().length, @@ -6823,15 +6823,11 @@ suite('CopilotAgent', () => { } }); - test('session-addressed lookups resolve the session-backed chat by its host-chosen scope, not a rebuilt default-chat URI', async () => { + test('MCP requests route to the exact host chat instead of the owning session', async () => { const agent = createTestAgent(disposables); try { const session = AgentSession.uri('copilotcli', 'scope-resolved-session'); - // Bound to a NON-default chat URI with an SDK id unrelated to - // the AH session id: neither `buildDefaultChatUri(session)` nor - // an `AgentSession.id(session)` SDK lookup would find it. Only - // the host-chosen persistence scope (`resourceUri === session`) - // identifies it as the session-backed chat. + // The Agent Host session id, chat id, and SDK id intentionally differ. const boundChat = URI.parse(buildChatUri(session, 'host-picked')); setLiveChatStub(agent, 'unrelated-sdk-id', { sessionId: 'unrelated-sdk-id', @@ -6844,19 +6840,19 @@ suite('CopilotAgent', () => { dispose: () => { }, }, boundChat); - assert.strictEqual(await agent.handleMcpRequest(session, 'srv', 'tools/call', undefined), 'srv/tools/call'); + assert.strictEqual(await agent.handleMcpRequest(boundChat, 'srv', 'tools/call', undefined), 'srv/tools/call'); } finally { await disposeAgent(agent); } }); - test('handleMcpRequest rejects when the session has no live session-backed chat', async () => { + test('handleMcpRequest rejects when the exact chat has no live runtime', async () => { const agent = createTestAgent(disposables); try { const session = AgentSession.uri('copilotcli', 'no-live-chat'); await assert.rejects( - () => agent.handleMcpRequest(session, 'srv', 'tools/list', undefined), - /Method not found: no active session no-live-chat/, + () => agent.handleMcpRequest(defaultChatUri(session), 'srv', 'tools/list', undefined), + /Method not found: no active chat/, ); } finally { await disposeAgent(agent); @@ -9196,7 +9192,7 @@ suite('CopilotAgent', () => { const sharedTool: ToolDefinition = { name: 'shared', description: 'Shared tool', inputSchema: { type: 'object', properties: {} } }; // Both clients provide the tool; the host fans A out to this chat // and B out to a different one only. - agent.getOrCreateActiveClient(session, session, { clientId: 'client-A' }).tools = [sharedTool]; + agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-A' }).tools = [sharedTool]; agent.getOrCreateActiveClient(otherChat, session, { clientId: 'client-B' }).tools = [sharedTool]; const mockSession = new MockCopilotSession(); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index f86e33c0393f97..65de73ef9bb3ee 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -41,6 +41,7 @@ import { STREAMING_TOOL_DISPLAY_INTERVAL_MS } from '../../common/streamingToolCa import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization } from '../../common/state/protocol/channels-session/state.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { buildNonPtyShellTerminalUri } from '../../node/copilot/copilotNonPtyShellTerminals.js'; +import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; import { buildSandboxConfigForSdk } from '../../node/copilot/sandboxConfigForSdk.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; @@ -682,7 +683,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { const parentSessionUri = AgentSession.uri('copilot', 'test-session-1'); const sessionUri = options?.sessionUri ?? parentSessionUri; - const chatChannelUri = options?.chatChannelUri ?? URI.parse(buildDefaultChatUri(parentSessionUri)); + const chatChannelUri = options?.chatChannelUri ?? URI.parse(buildDefaultChatUri(sessionUri)); const mockSession = new MockCopilotSession(); options?.configureMockSession?.(mockSession); @@ -4864,7 +4865,7 @@ suite('CopilotAgentSession', () => { mcpServerName: 'docs', ui: { resourceUri: 'ui://docs', - channel: 'mcp://copilot/test-session-1/docs', + channel: buildMcpChannel(URI.parse(buildDefaultChatUri(AgentSession.uri('copilot', 'test-session-1'))), 'docs'), }, }, }); diff --git a/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts b/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts index c2fd8cb7ed9a3d..0ceacf275c24e5 100644 --- a/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts +++ b/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts @@ -8,11 +8,11 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agent.js'; -import { FileEditKind, MessageKind, ResponsePartKind, ToolResultContentType } from '../../common/state/sessionState.js'; +import { FileEditKind, MessageKind, ResponsePartKind, ToolResultContentType, buildChatUri } from '../../common/state/sessionState.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { parseSessionDbUri } from '../../common/sessionDbUri.js'; import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js'; -import { mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; +import { mapSessionEvents as mapSessionEventsWithRouting } from '../../node/copilot/mapSessionEvents.js'; import { toSessionEvents, type ISessionEvent } from './copilotTestEvents.js'; suite('mapSessionEventsToHistoryRecords', () => { @@ -21,6 +21,10 @@ suite('mapSessionEventsToHistoryRecords', () => { let db: SessionDatabase | undefined; const session = AgentSession.uri('copilot', 'test-session'); + function mapSessionEvents(session: URI, db: undefined, events: Parameters[2]) { + return mapSessionEventsWithRouting(session, db, events, URI.parse(buildChatUri(session, 'default'))); + } + teardown(async () => { disposables.clear(); await db?.close(); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.perf.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.perf.test.ts index e81a0c9db4133d..b5190129134389 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.perf.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.perf.test.ts @@ -7,8 +7,10 @@ import assert from 'assert'; import type { SessionEvent } from '@github/copilot-sdk'; import { readFileSync } from 'fs'; import { StopWatch } from '../../../../base/common/stopwatch.js'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agent.js'; +import { buildChatUri } from '../../common/state/sessionState.js'; import { mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; interface BenchmarkResult { @@ -66,7 +68,8 @@ async function runBenchmarkRound(path: string): Promise { const parseMs = parse.elapsed(); const map = StopWatch.create(); - const restored = await mapSessionEvents(AgentSession.uri('copilot', 'event-restoration-benchmark'), undefined, events); + const session = AgentSession.uri('copilot', 'event-restoration-benchmark'); + const restored = await mapSessionEvents(session, undefined, events, URI.parse(buildChatUri(session, 'default'))); const mapMs = map.elapsed(); const serialize = StopWatch.create(); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index f71b5274c54ce0..121c976b296595 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -8,10 +8,14 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { AgentSession } from '../../common/agent.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart, type ToolResultContent } from '../../common/state/sessionState.js'; -import { appendSdkToolResultContent, mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart, type ToolResultContent } from '../../common/state/sessionState.js'; +import { appendSdkToolResultContent, mapSessionEvents as mapSessionEventsWithRouting, type IMapSessionEventsOptions } from '../../node/copilot/mapSessionEvents.js'; import { toSessionEvents, type ISessionEvent } from './copilotTestEvents.js'; +function mapSessionEvents(session: URI, db: undefined, events: Parameters[2], options: IMapSessionEventsOptions | undefined = undefined) { + return mapSessionEventsWithRouting(session, db, events, URI.parse(buildChatUri(session, 'default')), options); +} + suite('mapSessionEvents — history replay', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -141,7 +145,7 @@ suite('mapSessionEvents — history replay', () => { { type: 'tool.execution_complete', data: { toolCallId: 'tc-1', success: true } }, ]; - const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events), URI.file('/workspace')); + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events), { workingDirectory: URI.file('/workspace') }); const part = turns[0].responseParts.find(part => part.kind === ResponsePartKind.ToolCall) as ToolCallResponsePart | undefined; assert.ok(part); assert.deepStrictEqual({ @@ -198,7 +202,9 @@ suite('mapSessionEvents — history replay', () => { }, ]; - const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + const chatUri = URI.parse(buildChatUri(session, 'restored-chat')); + const sdkConversationUri = URI.parse('copilot-sdk:/conversation-123'); + const { turns } = await mapSessionEventsWithRouting(sdkConversationUri, undefined, toSessionEvents(events), chatUri); const part = turns[0].responseParts[0] as ToolCallResponsePart; assert.strictEqual(part.kind, ResponsePartKind.ToolCall); @@ -215,7 +221,7 @@ suite('mapSessionEvents — history replay', () => { mcpToolName: 'get_me', ui: { resourceUri: 'ui://github-mcp-server/get-me', - channel: 'mcp://copilot/test-session/GitHub', + channel: `mcp://copilot/${encodeURIComponent(chatUri.toString())}/GitHub`, }, }, }); diff --git a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts index 92a2f4ae9c43cc..c56cf43c924a4b 100644 --- a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts @@ -5,15 +5,22 @@ import assert from 'assert'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { AgentSession } from '../../../common/agent.js'; import { isCustomizationEnabled } from '../../../common/customizationEnablement.js'; import { ActionType } from '../../../common/state/protocol/common/actions.js'; import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, SessionStatus, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState, type PluginCustomization } from '../../../common/state/protocol/channels-session/state.js'; +import { buildChatUri } from '../../../common/state/sessionState.js'; import type { SessionAction } from '../../../common/state/sessionActions.js'; import { AgentHostStateManager } from '../../../node/agentHostStateManager.js'; -import { getEffectiveMcpServerCustomizations, McpCustomizationController, findMcpChildId, findMcpServerName, parseMcpChannelUri, type ISdkMcpServer } from '../../../node/shared/mcpCustomizationController.js'; +import { buildMcpChannel, getEffectiveMcpServerCustomizations, McpCustomizationController, findMcpChildId, findMcpServerName, parseMcpChannelUri, type ISdkMcpServer } from '../../../node/shared/mcpCustomizationController.js'; + +const SESSION_URI = AgentSession.uri('copilot', 'session-1'); +const CHAT_URI = URI.parse(buildChatUri(SESSION_URI, 'chat-1')); +const MCP_FS_CHANNEL = buildMcpChannel(CHAT_URI, 'fs'); +const MCP_SEARCH_CHANNEL = buildMcpChannel(CHAT_URI, 'search'); function harness(store: Pick, opts: { customizations?: readonly Customization[]; @@ -23,7 +30,7 @@ function harness(store: Pick, opts: { } = {}) { const actions: SessionAction[] = []; const stateManager = store.add(new AgentHostStateManager(new NullLogService())); - const sessionUri = AgentSession.uri('copilot', 'session-1'); + const sessionUri = SESSION_URI; const session = sessionUri.toString(); stateManager.createSession({ resource: session, @@ -47,9 +54,7 @@ function harness(store: Pick, opts: { }); } const controller = new McpCustomizationController({ - providerId: 'copilot', - sessionId: 'session-1', - sessionUri, + chatUri: CHAT_URI, emit: a => actions.push(a), pluginMcpServerSources: opts.pluginMcpServerSources, resolveEnablement: opts.resolveEnablement, @@ -124,7 +129,7 @@ suite('McpCustomizationController', () => { type: ActionType.SessionMcpServerStateChanged, id: 'mcp-child:demo:fs', state: { kind: McpServerStatus.Ready }, - channel: 'mcp://copilot/session-1/fs', + channel: MCP_FS_CHANNEL, }, { type: ActionType.SessionMcpServerStateChanged, @@ -136,7 +141,7 @@ suite('McpCustomizationController', () => { type: ActionType.SessionMcpServerStateChanged, id: 'mcp-child:demo:fs', state: { kind: McpServerStatus.Ready }, - channel: 'mcp://copilot/session-1/fs', + channel: MCP_FS_CHANNEL, }, ]); assert.deepStrictEqual(controller.topLevelCustomizations(), []); @@ -158,7 +163,7 @@ suite('McpCustomizationController', () => { uri: expectedId, name: 'search', state: { kind: McpServerStatus.Ready }, - channel: 'mcp://copilot/session-1/search', + channel: MCP_SEARCH_CHANNEL, mcpApp: { capabilities: { serverTools: { listChanged: true }, serverResources: {}, sampling: {} } }, }, }, @@ -170,7 +175,7 @@ suite('McpCustomizationController', () => { uri: expectedId, name: 'search', state: { kind: McpServerStatus.Ready }, - channel: 'mcp://copilot/session-1/search', + channel: MCP_SEARCH_CHANNEL, mcpApp: { capabilities: { serverTools: { listChanged: true }, serverResources: {}, sampling: {} } }, }, ]); @@ -278,7 +283,7 @@ suite('McpCustomizationController', () => { type: ActionType.SessionMcpServerStateChanged, id: 'mcp-child:demo:fs', state: { kind: McpServerStatus.Ready }, - channel: 'mcp://copilot/session-1/fs', + channel: MCP_FS_CHANNEL, }, { type: ActionType.SessionMcpServerStateChanged, @@ -296,7 +301,7 @@ suite('McpCustomizationController', () => { controller.applyOne(server('search', starting())); assert.deepStrictEqual(controller.runtimeStates.get(), new Map([ - ['mcp-child:demo:fs', { state: { kind: McpServerStatus.Ready }, channel: 'mcp://copilot/session-1/fs' }], + ['mcp-child:demo:fs', { state: { kind: McpServerStatus.Ready }, channel: MCP_FS_CHANNEL }], ['mcp-top-level:copilot:session-1:search', { state: { kind: McpServerStatus.Starting }, channel: undefined }], ])); assert.strictEqual(controller.serverNameForCustomizationId('mcp-child:demo:fs'), 'fs'); @@ -395,25 +400,26 @@ suite('McpCustomizationController', () => { type: ActionType.SessionMcpServerStateChanged, id: 'mcp-child:demo:fs', state: { kind: McpServerStatus.Ready }, - channel: 'mcp://copilot/session-1/fs', + channel: MCP_FS_CHANNEL, }, ]); }); test('parseMcpChannelUri round-trips the controller-minted channel URI', () => { - const channel = 'mcp://copilot/session-1/fs'; - assert.deepStrictEqual(parseMcpChannelUri(channel), { + const route = parseMcpChannelUri(MCP_FS_CHANNEL); + assert.deepStrictEqual(route && { ...route, chatUri: route.chatUri.toString() }, { providerId: 'copilot', - sessionId: 'session-1', + chatUri: CHAT_URI.toString(), serverName: 'fs', }); }); test('parseMcpChannelUri decodes URL-encoded path segments', () => { - const channel = 'mcp://copilot/session%2F1/my%20server'; - assert.deepStrictEqual(parseMcpChannelUri(channel), { + const chatUri = URI.parse(buildChatUri(AgentSession.uri('copilot', 'session/1'), 'chat with spaces')); + const route = parseMcpChannelUri(buildMcpChannel(chatUri, 'my server')); + assert.deepStrictEqual(route && { ...route, chatUri: route.chatUri.toString() }, { providerId: 'copilot', - sessionId: 'session/1', + chatUri: chatUri.toString(), serverName: 'my server', }); }); @@ -424,6 +430,7 @@ suite('McpCustomizationController', () => { assert.strictEqual(parseMcpChannelUri('mcp:///session/server'), undefined); assert.strictEqual(parseMcpChannelUri('mcp://copilot/session-only'), undefined); assert.strictEqual(parseMcpChannelUri('mcp://copilot/session/'), undefined); + assert.strictEqual(parseMcpChannelUri(MCP_FS_CHANNEL.replace('mcp://copilot/', 'mcp://codex/')), undefined); // Bad percent escapes must not throw — caller turns undefined // into a clean Method not found, not an internal error. assert.strictEqual(parseMcpChannelUri('mcp://copilot/bad%/server'), undefined); From d0d36cddefd72982a79751e444db80a46d717b49 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sat, 15 Aug 2026 23:17:18 -0700 Subject: [PATCH 2/4] agentHost: Expand E2E coverage (#331056) * agentHost: Expand E2E coverage Add broad deterministic coverage for protocol, filesystem, terminal, completion, OTLP, reconnect, and session tool behavior across the conformance and provider suites. Harden the new cases with repeated full-suite and focused stress runs. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: Avoid duplicate resource watch subscription Reuse the first subscription when equivalent watch descriptors resolve to the same channel, preventing the watcher refcount from being incremented twice. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: Stabilize OTLP subscription coverage Replace the timing-sensitive duplicate-delivery count with a deterministic unsubscribe and resubscribe lifecycle assertion. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-context-accepts-an-open-session-link.yaml | 54 ++ ...ntext-accepts-explicit-summary-detail.yaml | 56 ++ ...gest-includes-completed-response-text.yaml | 56 ++ ...excludes-sessions-before-the-boundary.yaml | 37 ++ ...pts-sessions-before-a-future-boundary.yaml | 37 ++ ...ns-hides-archived-sessions-by-default.yaml | 50 ++ ...-returns-active-and-archived-sessions.yaml | 48 ++ ...combines-active-and-archived-sessions.yaml | 50 ++ ...-context-accepts-an-open-session-link.yaml | 58 ++ ...ntext-accepts-explicit-summary-detail.yaml | 60 ++ ...gest-includes-completed-response-text.yaml | 60 ++ ...excludes-sessions-before-the-boundary.yaml | 37 ++ ...pts-sessions-before-a-future-boundary.yaml | 37 ++ ...ns-hides-archived-sessions-by-default.yaml | 50 ++ ...-returns-active-and-archived-sessions.yaml | 48 ++ ...combines-active-and-archived-sessions.yaml | 54 ++ ...-context-accepts-an-open-session-link.yaml | 54 ++ ...ntext-accepts-explicit-summary-detail.yaml | 56 ++ ...gest-includes-completed-response-text.yaml | 56 ++ ...excludes-sessions-before-the-boundary.yaml | 37 ++ ...pts-sessions-before-a-future-boundary.yaml | 37 ++ ...ns-hides-archived-sessions-by-default.yaml | 46 ++ ...-returns-active-and-archived-sessions.yaml | 48 ++ ...combines-active-and-archived-sessions.yaml | 50 ++ .../test/node/e2e/coverage/summary.json | 400 ++++++------- .../e2e/harness/agentHostE2ETestHarness.ts | 30 +- .../node/e2e/suites/clientFilesystemSuite.ts | 542 ++++++++++++++++++ .../test/node/e2e/suites/hostFeaturesSuite.ts | 155 ++++- .../node/e2e/suites/protocolContractsSuite.ts | 428 ++++++++++++++ .../test/node/e2e/suites/serverToolsSuite.ts | 174 +++++- .../node/e2e/suites/stateOperationsSuite.ts | 297 ++++++++++ 31 files changed, 2988 insertions(+), 214 deletions(-) create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-accepts-an-open-session-link.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-accepts-explicit-summary-detail.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-digest-includes-completed-response-text.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-hides-archived-sessions-by-default.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-accepts-an-open-session-link.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-accepts-explicit-summary-detail.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-digest-includes-completed-response-text.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-hides-archived-sessions-by-default.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-accepts-an-open-session-link.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-accepts-explicit-summary-detail.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-digest-includes-completed-response-text.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-hides-archived-sessions-by-default.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-accepts-an-open-session-link.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-accepts-an-open-session-link.yaml new file mode 100644 index 00000000000000..54d5c23cc05e0a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-accepts-an-open-session-link.yaml @@ -0,0 +1,54 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "LINK_READY". + response: + content: LINK_READY + stopReason: end_turn + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "LINK_READY". + - role: assistant + content: LINK_READY + - role: user + content: Call get_session_context exactly once with session "agent-host-session://claude/${uuid_0}", then reply exactly "read". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__get_session_context + input: + session: agent-host-session://claude/${uuid_0} + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "LINK_READY". + - role: assistant + content: LINK_READY + - role: user + content: Call get_session_context exactly once with session "agent-host-session://claude/${uuid_0}", then reply exactly "read". + - role: assistant + content: + - type: tool_use + name: mcp__host__get_session_context + input: + session: agent-host-session://claude/${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"session":"claude:/${uuid_0}","openLink":"agent-host-session://claude/${uuid_0}","detail":"summary","transcript":[{"turn":1,"state":"complete","user":"Reply exactly \"LINK_READY\".","assistant":"LINK_READY"},{"turn":2,"state":"inProgress","user":"Call get_session_context exactly once with session \"agent-host-session://claude/${uuid_0}\", then reply exactly \"read\"."}],"hasMoreHistory":false,"truncated":false}' + response: + content: read + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-accepts-explicit-summary-detail.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-accepts-explicit-summary-detail.yaml new file mode 100644 index 00000000000000..7685abef8e7ca2 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-accepts-explicit-summary-detail.yaml @@ -0,0 +1,56 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "SUMMARY_READY". + response: + content: SUMMARY_READY + stopReason: end_turn + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "SUMMARY_READY". + - role: assistant + content: SUMMARY_READY + - role: user + content: Call get_session_context exactly once with session "claude:/${uuid_0}" and detail "summary", then reply exactly "read". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__get_session_context + input: + session: claude:/${uuid_0} + detail: summary + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "SUMMARY_READY". + - role: assistant + content: SUMMARY_READY + - role: user + content: Call get_session_context exactly once with session "claude:/${uuid_0}" and detail "summary", then reply exactly "read". + - role: assistant + content: + - type: tool_use + name: mcp__host__get_session_context + input: + session: claude:/${uuid_0} + detail: summary + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"session":"claude:/${uuid_0}","openLink":"agent-host-session://claude/${uuid_0}","detail":"summary","transcript":[{"turn":1,"state":"complete","user":"Reply exactly \"SUMMARY_READY\".","assistant":"SUMMARY_READY"},{"turn":2,"state":"inProgress","user":"Call get_session_context exactly once with session \"claude:/${uuid_0}\" and detail \"summary\", then reply exactly \"read\"."}],"hasMoreHistory":false,"truncated":false}' + response: + content: read + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-digest-includes-completed-response-text.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-digest-includes-completed-response-text.yaml new file mode 100644 index 00000000000000..fe5d5c0b77d212 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-get-session-context-digest-includes-completed-response-text.yaml @@ -0,0 +1,56 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "DIGEST_READY". + response: + content: DIGEST_READY + stopReason: end_turn + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "DIGEST_READY". + - role: assistant + content: DIGEST_READY + - role: user + content: Call get_session_context exactly once with session "claude:/${uuid_0}" and detail "digest", then reply exactly "read". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__get_session_context + input: + session: claude:/${uuid_0} + detail: digest + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "DIGEST_READY". + - role: assistant + content: DIGEST_READY + - role: user + content: Call get_session_context exactly once with session "claude:/${uuid_0}" and detail "digest", then reply exactly "read". + - role: assistant + content: + - type: tool_use + name: mcp__host__get_session_context + input: + session: claude:/${uuid_0} + detail: digest + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"session":"claude:/${uuid_0}","openLink":"agent-host-session://claude/${uuid_0}","detail":"digest","transcript":[{"turn":1,"state":"complete","user":"Reply exactly \"DIGEST_READY\".","assistant":"DIGEST_READY"},{"turn":2,"state":"inProgress","user":"Call get_session_context exactly once with session \"claude:/${uuid_0}\" and detail \"digest\", then reply exactly \"read\".","toolCalls":["mcp__host__get_session_context"]}],"hasMoreHistory":false,"truncated":false}' + response: + content: read + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml new file mode 100644 index 00000000000000..8d192a1255db58 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml @@ -0,0 +1,37 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdAfter "2999-01-01T00:00:00Z", then reply exactly "filtered". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__list_sessions + input: + createdAfter: '2999-01-01T00:00:00Z' + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdAfter "2999-01-01T00:00:00Z", then reply exactly "filtered". + - role: assistant + content: + - type: tool_use + name: mcp__host__list_sessions + input: + createdAfter: '2999-01-01T00:00:00Z' + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[]}' + response: + content: filtered + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml new file mode 100644 index 00000000000000..23f093f1a7cf62 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml @@ -0,0 +1,37 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdBefore "2999-01-01T00:00:00Z", then reply exactly "filtered". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__list_sessions + input: + createdBefore: '2999-01-01T00:00:00Z' + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdBefore "2999-01-01T00:00:00Z", then reply exactly "filtered". + - role: assistant + content: + - type: tool_use + name: mcp__host__list_sessions + input: + createdBefore: '2999-01-01T00:00:00Z' + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with createdBefore \"2999-01-01T00:00:00Z\", then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-16T00:05:45.643Z","modifiedAt":"2026-08-16T00:05:45.208Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: filtered + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-hides-archived-sessions-by-default.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-hides-archived-sessions-by-default.yaml new file mode 100644 index 00000000000000..69cab21d83a2b4 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-hides-archived-sessions-by-default.yaml @@ -0,0 +1,50 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ARCHIVED_HIDDEN_READY". + response: + content: ARCHIVED_HIDDEN_READY + stopReason: end_turn + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}", then reply exactly "listed". + response: + content: + - type: text + text: I'll list the sessions for that workspace. + - type: tool_use + id: toolcall_0 + name: mcp__host__list_sessions + input: + workspace: ${workdir} + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}", then reply exactly "listed". + - role: assistant + content: + - type: text + text: I'll list the sessions for that workspace. + - type: tool_use + name: mcp__host__list_sessions + input: + workspace: ${workdir} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\", then reply exactly \"listed\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:03:36.971Z","modifiedAt":"2026-08-15T23:03:36.554Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: listed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml new file mode 100644 index 00000000000000..d2e04d522a968a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml @@ -0,0 +1,48 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ARCHIVED_INCLUDED_READY". + response: + content: ARCHIVED_INCLUDED_READY + stopReason: end_turn + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}" and includeArchived true, then reply exactly "listed". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__list_sessions + input: + workspace: ${workdir} + includeArchived: true + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}" and includeArchived true, then reply exactly "listed". + - role: assistant + content: + - type: tool_use + name: mcp__host__list_sessions + input: + workspace: ${workdir} + includeArchived: true + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\" and includeArchived true, then reply exactly \"listed","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:11:26.581Z","modifiedAt":"2026-08-15T23:11:26.168Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]},{"session":"claude:/${uuid_1}","title":"Reply exactly \"ARCHIVED_INCLUDED_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:11:24.281Z","modifiedAt":"2026-08-15T23:11:26.162Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_1}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_1}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: listed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml new file mode 100644 index 00000000000000..96709bea7195e9 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml @@ -0,0 +1,50 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "COMBINED_TARGET_READY". + response: + content: COMBINED_TARGET_READY + stopReason: end_turn + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with status ["inProgress", "archived"], then reply exactly "filtered". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__list_sessions + input: + status: + - inProgress + - archived + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with status ["inProgress", "archived"], then reply exactly "filtered". + - role: assistant + content: + - type: tool_use + name: mcp__host__list_sessions + input: + status: + - inProgress + - archived + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"claude:/${uuid_0}","title":"Call list_sessions exactly once with status [\"inProgress\", \"archived\"], then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:39:43.777Z","modifiedAt":"2026-08-15T23:39:43.354Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]},{"session":"claude:/${uuid_1}","title":"Reply exactly \"COMBINED_TARGET_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:39:41.314Z","modifiedAt":"2026-08-15T23:39:43.346Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"claude:/${uuid_1}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"claude:/${uuid_1}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: filtered + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-accepts-an-open-session-link.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-accepts-an-open-session-link.yaml new file mode 100644 index 00000000000000..7167d6e2ab5839 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-accepts-an-open-session-link.yaml @@ -0,0 +1,58 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "LINK_READY". + response: + content: LINK_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "LINK_READY". + - role: assistant + content: LINK_READY + - role: user + content: Call get_session_context exactly once with session "agent-host-session://codex/${uuid_0}", then reply exactly "read". + response: + content: + - type: text + text: I’ll fetch that session context now. + - type: tool_use + id: toolcall_0 + name: get_session_context + input: + session: agent-host-session://codex/${uuid_0} + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "LINK_READY". + - role: assistant + content: LINK_READY + - role: user + content: Call get_session_context exactly once with session "agent-host-session://codex/${uuid_0}", then reply exactly "read". + - role: assistant + content: I’ll fetch that session context now. + - role: assistant + content: + - type: tool_use + name: get_session_context + input: + session: agent-host-session://codex/${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"session":"codex:/${uuid_0}","openLink":"agent-host-session://codex/${uuid_0}","detail":"summary","transcript":[{"turn":1,"state":"complete","user":"Reply exactly \"LINK_READY\".","assistant":"LINK_READY"},{"turn":2,"state":"inProgress","user":"Call get_session_context exactly once with session \"agent-host-session://codex/${uuid_0}\", then reply exactly \"read\".","assistant":"I’ll fetch that session context now."}],"hasMoreHistory":false,"truncated":false}' + response: + content: read + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-accepts-explicit-summary-detail.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-accepts-explicit-summary-detail.yaml new file mode 100644 index 00000000000000..a06b4929f13204 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-accepts-explicit-summary-detail.yaml @@ -0,0 +1,60 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "SUMMARY_READY". + response: + content: SUMMARY_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "SUMMARY_READY". + - role: assistant + content: SUMMARY_READY + - role: user + content: Call get_session_context exactly once with session "codex:/${uuid_0}" and detail "summary", then reply exactly "read". + response: + content: + - type: text + text: Got it — I’ll fetch that session summary once now. + - type: tool_use + id: toolcall_0 + name: get_session_context + input: + session: codex:/${uuid_0} + detail: summary + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "SUMMARY_READY". + - role: assistant + content: SUMMARY_READY + - role: user + content: Call get_session_context exactly once with session "codex:/${uuid_0}" and detail "summary", then reply exactly "read". + - role: assistant + content: Got it — I’ll fetch that session summary once now. + - role: assistant + content: + - type: tool_use + name: get_session_context + input: + session: codex:/${uuid_0} + detail: summary + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"session":"codex:/${uuid_0}","openLink":"agent-host-session://codex/${uuid_0}","detail":"summary","transcript":[{"turn":1,"state":"complete","user":"Reply exactly \"SUMMARY_READY\".","assistant":"SUMMARY_READY"},{"turn":2,"state":"inProgress","user":"Call get_session_context exactly once with session \"codex:/${uuid_0}\" and detail \"summary\", then reply exactly \"read\".","assistant":"Got it — I’ll fetch that session summary once now."}],"hasMoreHistory":false,"truncated":false}' + response: + content: read + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-digest-includes-completed-response-text.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-digest-includes-completed-response-text.yaml new file mode 100644 index 00000000000000..d7374a0c390cdc --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-get-session-context-digest-includes-completed-response-text.yaml @@ -0,0 +1,60 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "DIGEST_READY". + response: + content: DIGEST_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "DIGEST_READY". + - role: assistant + content: DIGEST_READY + - role: user + content: Call get_session_context exactly once with session "codex:/${uuid_0}" and detail "digest", then reply exactly "read". + response: + content: + - type: text + text: I’ll fetch that session digest now, then return the exact reply. + - type: tool_use + id: toolcall_0 + name: get_session_context + input: + session: codex:/${uuid_0} + detail: digest + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "DIGEST_READY". + - role: assistant + content: DIGEST_READY + - role: user + content: Call get_session_context exactly once with session "codex:/${uuid_0}" and detail "digest", then reply exactly "read". + - role: assistant + content: I’ll fetch that session digest now, then return the exact reply. + - role: assistant + content: + - type: tool_use + name: get_session_context + input: + session: codex:/${uuid_0} + detail: digest + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"session":"codex:/${uuid_0}","openLink":"agent-host-session://codex/${uuid_0}","detail":"digest","transcript":[{"turn":1,"state":"complete","user":"Reply exactly \"DIGEST_READY\".","assistant":"DIGEST_READY"},{"turn":2,"state":"inProgress","user":"Call get_session_context exactly once with session \"codex:/${uuid_0}\" and detail \"digest\", then reply exactly \"read\".","assistant":"I’ll fetch that session digest now, then return the exact reply.","toolCalls":["get_session_context"]}],"hasMoreHistory":false,"truncated":false}' + response: + content: read + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml new file mode 100644 index 00000000000000..3b2965c2d55c45 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml @@ -0,0 +1,37 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdAfter "2999-01-01T00:00:00Z", then reply exactly "filtered". + response: + content: + - type: tool_use + id: toolcall_0 + name: list_sessions + input: + createdAfter: '2999-01-01T00:00:00Z' + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdAfter "2999-01-01T00:00:00Z", then reply exactly "filtered". + - role: assistant + content: + - type: tool_use + name: list_sessions + input: + createdAfter: '2999-01-01T00:00:00Z' + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[]}' + response: + content: filtered + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml new file mode 100644 index 00000000000000..ea63eb7d4f2d52 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml @@ -0,0 +1,37 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdBefore "2999-01-01T00:00:00Z", then reply exactly "filtered". + response: + content: + - type: tool_use + id: toolcall_0 + name: list_sessions + input: + createdBefore: '2999-01-01T00:00:00Z' + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdBefore "2999-01-01T00:00:00Z", then reply exactly "filtered". + - role: assistant + content: + - type: tool_use + name: list_sessions + input: + createdBefore: '2999-01-01T00:00:00Z' + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"codex:/${uuid_0}","title":"Call list_sessions exactly once with createdBefore \"2999-01-01T00:00:00Z\", then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-16T00:07:45.791Z","modifiedAt":"2026-08-16T00:07:45.848Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"codex:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"codex:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: filtered + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-hides-archived-sessions-by-default.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-hides-archived-sessions-by-default.yaml new file mode 100644 index 00000000000000..70b6b488f5dbda --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-hides-archived-sessions-by-default.yaml @@ -0,0 +1,50 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "ARCHIVED_HIDDEN_READY". + response: + content: ARCHIVED_HIDDEN_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}", then reply exactly "listed". + response: + content: + - type: text + text: Got it — I’ll run that one `list_sessions` call now. + - type: tool_use + id: toolcall_0 + name: list_sessions + input: + workspace: ${workdir} + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}", then reply exactly "listed". + - role: assistant + content: Got it — I’ll run that one `list_sessions` call now. + - role: assistant + content: + - type: tool_use + name: list_sessions + input: + workspace: ${workdir} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"codex:/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\", then reply exactly \"listed\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:04:23.686Z","modifiedAt":"2026-08-15T23:04:25.853Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"codex:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"codex:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: listed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml new file mode 100644 index 00000000000000..d5a3dd0d4664b3 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml @@ -0,0 +1,48 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "ARCHIVED_INCLUDED_READY". + response: + content: ARCHIVED_INCLUDED_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}" and includeArchived true, then reply exactly "listed". + response: + content: + - type: tool_use + id: toolcall_0 + name: list_sessions + input: + workspace: ${workdir} + includeArchived: true + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}" and includeArchived true, then reply exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_sessions + input: + workspace: ${workdir} + includeArchived: true + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"codex:/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\" and includeArchived true, then reply exactly \"listed","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:11:39.997Z","modifiedAt":"2026-08-15T23:11:42.015Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"codex:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"codex:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]},{"session":"codex:/${uuid_1}","title":"Reply exactly \"ARCHIVED_INCLUDED_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:11:40.132Z","modifiedAt":"2026-08-15T23:11:42.012Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"codex:/${uuid_1}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"codex:/${uuid_1}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: listed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml new file mode 100644 index 00000000000000..85ea9fb6b4e3c0 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml @@ -0,0 +1,54 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "COMBINED_TARGET_READY". + response: + content: COMBINED_TARGET_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with status ["inProgress", "archived"], then reply exactly "filtered". + response: + content: + - type: text + text: Got it — I’ll run that exact session filter now. + - type: tool_use + id: toolcall_0 + name: list_sessions + input: + status: + - inProgress + - archived + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with status ["inProgress", "archived"], then reply exactly "filtered". + - role: assistant + content: Got it — I’ll run that exact session filter now. + - role: assistant + content: + - type: tool_use + name: list_sessions + input: + status: + - inProgress + - archived + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"codex:/${uuid_0}","title":"Call list_sessions exactly once with status [\"inProgress\", \"archived\"], then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:39:55.468Z","modifiedAt":"2026-08-15T23:39:57.604Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"codex:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"codex:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]},{"session":"codex:/${uuid_1}","title":"Reply exactly \"COMBINED_TARGET_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:39:55.588Z","modifiedAt":"2026-08-15T23:39:57.601Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"codex:/${uuid_1}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"codex:/${uuid_1}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: filtered + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-accepts-an-open-session-link.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-accepts-an-open-session-link.yaml new file mode 100644 index 00000000000000..4a44c4fe1a7a1e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-accepts-an-open-session-link.yaml @@ -0,0 +1,54 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "LINK_READY". + response: + content: LINK_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "LINK_READY". + - role: assistant + content: LINK_READY + - role: user + content: Call get_session_context exactly once with session "agent-host-session://copilotcli/e2e-server-tools-context-link", then reply exactly "read". + response: + content: + - type: tool_use + id: toolcall_0 + name: get_session_context + input: + session: agent-host-session://copilotcli/e2e-server-tools-context-link + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "LINK_READY". + - role: assistant + content: LINK_READY + - role: user + content: Call get_session_context exactly once with session "agent-host-session://copilotcli/e2e-server-tools-context-link", then reply exactly "read". + - role: assistant + content: + - type: tool_use + name: get_session_context + input: + session: agent-host-session://copilotcli/e2e-server-tools-context-link + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"session":"copilotcli:/e2e-server-tools-context-link","openLink":"agent-host-session://copilotcli/e2e-server-tools-context-link","detail":"summary","transcript":[{"turn":1,"state":"complete","user":"Reply exactly \"LINK_READY\".","assistant":"LINK_READY"},{"turn":2,"state":"inProgress","user":"Call get_session_context exactly once with session \"agent-host-session://copilotcli/e2e-server-tools-context-link\", then reply exactly \"read\"."}],"hasMoreHistory":false,"truncated":false}' + response: + content: read + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-accepts-explicit-summary-detail.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-accepts-explicit-summary-detail.yaml new file mode 100644 index 00000000000000..13dcb2bcec2b51 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-accepts-explicit-summary-detail.yaml @@ -0,0 +1,56 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "SUMMARY_READY". + response: + content: SUMMARY_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "SUMMARY_READY". + - role: assistant + content: SUMMARY_READY + - role: user + content: Call get_session_context exactly once with session "copilotcli:/e2e-server-tools-context-explicit-summary" and detail "summary", then reply exactly "read". + response: + content: + - type: tool_use + id: toolcall_0 + name: get_session_context + input: + session: copilotcli:/e2e-server-tools-context-explicit-summary + detail: summary + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "SUMMARY_READY". + - role: assistant + content: SUMMARY_READY + - role: user + content: Call get_session_context exactly once with session "copilotcli:/e2e-server-tools-context-explicit-summary" and detail "summary", then reply exactly "read". + - role: assistant + content: + - type: tool_use + name: get_session_context + input: + session: copilotcli:/e2e-server-tools-context-explicit-summary + detail: summary + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"session":"copilotcli:/e2e-server-tools-context-explicit-summary","openLink":"agent-host-session://copilotcli/e2e-server-tools-context-explicit-summary","detail":"summary","transcript":[{"turn":1,"state":"complete","user":"Reply exactly \"SUMMARY_READY\".","assistant":"SUMMARY_READY"},{"turn":2,"state":"inProgress","user":"Call get_session_context exactly once with session \"copilotcli:/e2e-server-tools-context-explicit-summary\" and detail \"summary\", then reply exactly \"read\"."}],"hasMoreHistory":false,"truncated":false}' + response: + content: read + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-digest-includes-completed-response-text.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-digest-includes-completed-response-text.yaml new file mode 100644 index 00000000000000..f04fd5375350c5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-get-session-context-digest-includes-completed-response-text.yaml @@ -0,0 +1,56 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "DIGEST_READY". + response: + content: DIGEST_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "DIGEST_READY". + - role: assistant + content: DIGEST_READY + - role: user + content: Call get_session_context exactly once with session "copilotcli:/e2e-server-tools-context-digest" and detail "digest", then reply exactly "read". + response: + content: + - type: tool_use + id: toolcall_0 + name: get_session_context + input: + session: copilotcli:/e2e-server-tools-context-digest + detail: digest + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "DIGEST_READY". + - role: assistant + content: DIGEST_READY + - role: user + content: Call get_session_context exactly once with session "copilotcli:/e2e-server-tools-context-digest" and detail "digest", then reply exactly "read". + - role: assistant + content: + - type: tool_use + name: get_session_context + input: + session: copilotcli:/e2e-server-tools-context-digest + detail: digest + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"session":"copilotcli:/e2e-server-tools-context-digest","openLink":"agent-host-session://copilotcli/e2e-server-tools-context-digest","detail":"digest","transcript":[{"turn":1,"state":"complete","user":"Reply exactly \"DIGEST_READY\".","assistant":"DIGEST_READY"},{"turn":2,"state":"inProgress","user":"Call get_session_context exactly once with session \"copilotcli:/e2e-server-tools-context-digest\" and detail \"digest\", then reply exactly \"read\".","toolCalls":["get_session_context"]}],"hasMoreHistory":false,"truncated":false}' + response: + content: read + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml new file mode 100644 index 00000000000000..e6b8a7ee829656 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-createdafter-excludes-sessions-before-the-boundary.yaml @@ -0,0 +1,37 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdAfter "2999-01-01T00:00:00Z", then reply exactly "filtered". + response: + content: + - type: tool_use + id: toolcall_0 + name: list_sessions + input: + createdAfter: '2999-01-01T00:00:00Z' + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdAfter "2999-01-01T00:00:00Z", then reply exactly "filtered". + - role: assistant + content: + - type: tool_use + name: list_sessions + input: + createdAfter: '2999-01-01T00:00:00Z' + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[]}' + response: + content: filtered + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml new file mode 100644 index 00000000000000..b7b9b3f82ad118 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-createdbefore-accepts-sessions-before-a-future-boundary.yaml @@ -0,0 +1,37 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdBefore "2999-01-01T00:00:00Z", then reply exactly "filtered". + response: + content: + - type: tool_use + id: toolcall_0 + name: list_sessions + input: + createdBefore: '2999-01-01T00:00:00Z' + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with createdBefore "2999-01-01T00:00:00Z", then reply exactly "filtered". + - role: assistant + content: + - type: tool_use + name: list_sessions + input: + createdBefore: '2999-01-01T00:00:00Z' + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"copilotcli:/${uuid_0}","title":"Call list_sessions exactly once with createdBefore \"2999-01-01T00:00:00Z\", then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-16T00:06:20.199Z","modifiedAt":"2026-08-16T00:06:19.758Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"copilotcli:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"copilotcli:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: filtered + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-hides-archived-sessions-by-default.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-hides-archived-sessions-by-default.yaml new file mode 100644 index 00000000000000..6867f8c6d80a98 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-hides-archived-sessions-by-default.yaml @@ -0,0 +1,46 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ARCHIVED_HIDDEN_READY". + response: + content: ARCHIVED_HIDDEN_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}", then reply exactly "listed". + response: + content: + - type: tool_use + id: toolcall_0 + name: list_sessions + input: + workspace: ${workdir} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}", then reply exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_sessions + input: + workspace: ${workdir} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"copilotcli:/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\", then reply exactly \"listed\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:05:12.573Z","modifiedAt":"2026-08-15T23:05:12.170Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"copilotcli:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"copilotcli:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: listed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml new file mode 100644 index 00000000000000..e6dd8ff1be3732 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-includearchived-returns-active-and-archived-sessions.yaml @@ -0,0 +1,48 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ARCHIVED_INCLUDED_READY". + response: + content: ARCHIVED_INCLUDED_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}" and includeArchived true, then reply exactly "listed". + response: + content: + - type: tool_use + id: toolcall_0 + name: list_sessions + input: + workspace: ${workdir} + includeArchived: true + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with workspace "${workdir}" and includeArchived true, then reply exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_sessions + input: + workspace: ${workdir} + includeArchived: true + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"copilotcli:/${uuid_0}","title":"Call list_sessions exactly once with workspace \"${workdir}\" and includeArchived true, then reply exactly \"listed","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:12:01.625Z","modifiedAt":"2026-08-15T23:12:01.153Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"copilotcli:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"copilotcli:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]},{"session":"copilotcli:/${uuid_1}","title":"Reply exactly \"ARCHIVED_INCLUDED_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:11:59.721Z","modifiedAt":"2026-08-15T23:12:01.137Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"copilotcli:/${uuid_1}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"copilotcli:/${uuid_1}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: listed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml new file mode 100644 index 00000000000000..508c4427ab9d01 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-sessions-status-filter-combines-active-and-archived-sessions.yaml @@ -0,0 +1,50 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "COMBINED_TARGET_READY". + response: + content: COMBINED_TARGET_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with status ["inProgress", "archived"], then reply exactly "filtered". + response: + content: + - type: tool_use + id: toolcall_0 + name: list_sessions + input: + status: + - inProgress + - archived + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call list_sessions exactly once with status ["inProgress", "archived"], then reply exactly "filtered". + - role: assistant + content: + - type: tool_use + name: list_sessions + input: + status: + - inProgress + - archived + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"sessions":[{"session":"copilotcli:/${uuid_0}","title":"Call list_sessions exactly once with status [\"inProgress\", \"archived\"], then reply exactly \"filtered\".","status":"inProgress","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:40:12.159Z","modifiedAt":"2026-08-15T23:40:11.847Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"copilotcli:/${uuid_0}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"copilotcli:/${uuid_0}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]},{"session":"copilotcli:/${uuid_1}","title":"Reply exactly \"COMBINED_TARGET_READY\".","status":"idle,archived","workingDirectory":"file://${workdir}","unread":true,"createdAt":"2026-08-15T23:40:10.640Z","modifiedAt":"2026-08-15T23:40:11.841Z","changesets":[{"label":"All Changes","changeKind":"session","uriTemplate":"copilotcli:/${uuid_1}/changeset/session","description":"Show all changes made in this session"},{"label":"This Turn","changeKind":"turn","uriTemplate":"copilotcli:/${uuid_1}/changeset/turn/{turnId}","description":"Show changes made in this turn"}]}]}' + response: + content: filtered + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json index 6863ae43bd48b4..f247d9952b854f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json @@ -15,31 +15,31 @@ }, "total": { "statements": { - "covered": 86322, - "total": 113954, - "percentage": 75.75 + "covered": 86462, + "total": 114066, + "percentage": 75.79 }, "branches": { - "covered": 9917, - "total": 14872, - "percentage": 66.68 + "covered": 10023, + "total": 14969, + "percentage": 66.95 }, "functions": { - "covered": 3230, - "total": 4520, - "percentage": 71.46 + "covered": 3239, + "total": 4529, + "percentage": 71.51 }, "lines": { - "covered": 86322, - "total": 113954, - "percentage": 75.75 + "covered": 86462, + "total": 114066, + "percentage": 75.79 } }, "files": { "src/vs/platform/agentHost/common/agent.ts": { "statements": { - "covered": 1149, - "total": 1159, + "covered": 1152, + "total": 1162, "percentage": 99.13 }, "branches": { @@ -53,8 +53,8 @@ "percentage": 83.33 }, "lines": { - "covered": 1149, - "total": 1159, + "covered": 1152, + "total": 1162, "percentage": 99.13 } }, @@ -725,9 +725,9 @@ "percentage": 80.07 }, "branches": { - "covered": 25, - "total": 35, - "percentage": 71.42 + "covered": 29, + "total": 39, + "percentage": 74.35 }, "functions": { "covered": 11, @@ -769,9 +769,9 @@ "percentage": 86.48 }, "branches": { - "covered": 48, - "total": 60, - "percentage": 80 + "covered": 49, + "total": 61, + "percentage": 80.32 }, "functions": { "covered": 19, @@ -1292,14 +1292,14 @@ }, "src/vs/platform/agentHost/common/openSessionLink.ts": { "statements": { - "covered": 116, + "covered": 117, "total": 151, - "percentage": 76.82 + "percentage": 77.48 }, "branches": { - "covered": 12, - "total": 24, - "percentage": 50 + "covered": 15, + "total": 25, + "percentage": 60 }, "functions": { "covered": 4, @@ -1307,9 +1307,9 @@ "percentage": 40 }, "lines": { - "covered": 116, + "covered": 117, "total": 151, - "percentage": 76.82 + "percentage": 77.48 } }, "src/vs/platform/agentHost/common/otel/agentHostOTelService.ts": { @@ -1341,9 +1341,9 @@ "percentage": 74.22 }, "branches": { - "covered": 39, - "total": 50, - "percentage": 78 + "covered": 44, + "total": 52, + "percentage": 84.61 }, "functions": { "covered": 21, @@ -1759,9 +1759,9 @@ "percentage": 71.19 }, "branches": { - "covered": 62, - "total": 89, - "percentage": 69.66 + "covered": 61, + "total": 88, + "percentage": 69.31 }, "functions": { "covered": 6, @@ -2172,14 +2172,14 @@ }, "src/vs/platform/agentHost/common/state/sessionState.ts": { "statements": { - "covered": 1401, + "covered": 1403, "total": 1848, - "percentage": 75.81 + "percentage": 75.91 }, "branches": { - "covered": 160, - "total": 232, - "percentage": 68.96 + "covered": 162, + "total": 233, + "percentage": 69.52 }, "functions": { "covered": 58, @@ -2187,9 +2187,9 @@ "percentage": 68.23 }, "lines": { - "covered": 1401, + "covered": 1403, "total": 1848, - "percentage": 75.81 + "percentage": 75.91 } }, "src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts": { @@ -2331,9 +2331,9 @@ "percentage": 91.66 }, "branches": { - "covered": 44, - "total": 60, - "percentage": 73.33 + "covered": 45, + "total": 61, + "percentage": 73.77 }, "functions": { "covered": 17, @@ -2458,14 +2458,14 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts": { "statements": { - "covered": 265, + "covered": 264, "total": 296, - "percentage": 89.52 + "percentage": 89.18 }, "branches": { - "covered": 53, - "total": 69, - "percentage": 76.81 + "covered": 51, + "total": 68, + "percentage": 75 }, "functions": { "covered": 13, @@ -2473,21 +2473,21 @@ "percentage": 100 }, "lines": { - "covered": 265, + "covered": 264, "total": 296, - "percentage": 89.52 + "percentage": 89.18 } }, "src/vs/platform/agentHost/node/agentHostChangesetService.ts": { "statements": { - "covered": 1229, + "covered": 1227, "total": 1646, - "percentage": 74.66 + "percentage": 74.54 }, "branches": { - "covered": 185, - "total": 258, - "percentage": 71.7 + "covered": 180, + "total": 254, + "percentage": 70.86 }, "functions": { "covered": 56, @@ -2495,9 +2495,9 @@ "percentage": 80 }, "lines": { - "covered": 1229, + "covered": 1227, "total": 1646, - "percentage": 74.66 + "percentage": 74.54 } }, "src/vs/platform/agentHost/node/agentHostChangesetStateCache.ts": { @@ -2573,9 +2573,9 @@ "percentage": 94.44 }, "branches": { - "covered": 41, + "covered": 43, "total": 52, - "percentage": 78.84 + "percentage": 82.69 }, "functions": { "covered": 6, @@ -2700,14 +2700,14 @@ }, "src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts": { "statements": { - "covered": 558, + "covered": 554, "total": 723, - "percentage": 77.17 + "percentage": 76.62 }, "branches": { - "covered": 100, - "total": 143, - "percentage": 69.93 + "covered": 92, + "total": 137, + "percentage": 67.15 }, "functions": { "covered": 41, @@ -2715,9 +2715,9 @@ "percentage": 87.23 }, "lines": { - "covered": 558, + "covered": 554, "total": 723, - "percentage": 77.17 + "percentage": 76.62 } }, "src/vs/platform/agentHost/node/agentHostDatabase.ts": { @@ -2788,24 +2788,24 @@ }, "src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts": { "statements": { - "covered": 247, + "covered": 277, "total": 317, - "percentage": 77.91 + "percentage": 87.38 }, "branches": { - "covered": 36, - "total": 57, - "percentage": 63.15 + "covered": 60, + "total": 76, + "percentage": 78.94 }, "functions": { - "covered": 8, + "covered": 9, "total": 9, - "percentage": 88.88 + "percentage": 100 }, "lines": { - "covered": 247, + "covered": 277, "total": 317, - "percentage": 77.91 + "percentage": 87.38 } }, "src/vs/platform/agentHost/node/agentHostFileCompletionUtils.ts": { @@ -2832,14 +2832,14 @@ }, "src/vs/platform/agentHost/node/agentHostFileMonitorService.ts": { "statements": { - "covered": 165, + "covered": 166, "total": 185, - "percentage": 89.18 + "percentage": 89.72 }, "branches": { - "covered": 23, - "total": 35, - "percentage": 65.71 + "covered": 24, + "total": 36, + "percentage": 66.66 }, "functions": { "covered": 14, @@ -2847,9 +2847,9 @@ "percentage": 100 }, "lines": { - "covered": 165, + "covered": 166, "total": 185, - "percentage": 89.18 + "percentage": 89.72 } }, "src/vs/platform/agentHost/node/agentHostGitHubEndpointService.ts": { @@ -2903,9 +2903,9 @@ "percentage": 70.35 }, "branches": { - "covered": 263, - "total": 371, - "percentage": 70.88 + "covered": 267, + "total": 375, + "percentage": 71.2 }, "functions": { "covered": 61, @@ -2920,14 +2920,14 @@ }, "src/vs/platform/agentHost/node/agentHostGitStateService.ts": { "statements": { - "covered": 232, + "covered": 233, "total": 424, - "percentage": 54.71 + "percentage": 54.95 }, "branches": { - "covered": 63, - "total": 88, - "percentage": 71.59 + "covered": 66, + "total": 90, + "percentage": 73.33 }, "functions": { "covered": 9, @@ -2935,9 +2935,9 @@ "percentage": 64.28 }, "lines": { - "covered": 232, + "covered": 233, "total": 424, - "percentage": 54.71 + "percentage": 54.95 } }, "src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts": { @@ -3057,9 +3057,9 @@ "percentage": 81 }, "branches": { - "covered": 24, - "total": 32, - "percentage": 75 + "covered": 25, + "total": 33, + "percentage": 75.75 }, "functions": { "covered": 5, @@ -3140,9 +3140,9 @@ }, "src/vs/platform/agentHost/node/agentHostProxyResolver.ts": { "statements": { - "covered": 123, - "total": 167, - "percentage": 73.65 + "covered": 131, + "total": 179, + "percentage": 73.18 }, "branches": { "covered": 15, @@ -3155,9 +3155,9 @@ "percentage": 46.42 }, "lines": { - "covered": 123, - "total": 167, - "percentage": 73.65 + "covered": 131, + "total": 179, + "percentage": 73.18 } }, "src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts": { @@ -3470,14 +3470,14 @@ }, "src/vs/platform/agentHost/node/agentHostStateManager.ts": { "statements": { - "covered": 1638, + "covered": 1636, "total": 1776, - "percentage": 92.22 + "percentage": 92.11 }, "branches": { - "covered": 263, - "total": 310, - "percentage": 84.83 + "covered": 257, + "total": 305, + "percentage": 84.26 }, "functions": { "covered": 70, @@ -3485,9 +3485,9 @@ "percentage": 89.74 }, "lines": { - "covered": 1638, + "covered": 1636, "total": 1776, - "percentage": 92.22 + "percentage": 92.11 } }, "src/vs/platform/agentHost/node/agentHostStorageService.ts": { @@ -3607,9 +3607,9 @@ "percentage": 90.83 }, "branches": { - "covered": 119, - "total": 148, - "percentage": 80.4 + "covered": 120, + "total": 149, + "percentage": 80.53 }, "functions": { "covered": 35, @@ -3695,9 +3695,9 @@ "percentage": 71.12 }, "branches": { - "covered": 15, - "total": 30, - "percentage": 50 + "covered": 16, + "total": 31, + "percentage": 51.61 }, "functions": { "covered": 5, @@ -3800,24 +3800,24 @@ }, "src/vs/platform/agentHost/node/agentService.ts": { "statements": { - "covered": 4438, - "total": 5740, - "percentage": 77.31 + "covered": 4465, + "total": 5780, + "percentage": 77.24 }, "branches": { - "covered": 772, - "total": 1153, - "percentage": 66.95 + "covered": 790, + "total": 1176, + "percentage": 67.17 }, "functions": { - "covered": 188, - "total": 228, - "percentage": 82.45 + "covered": 193, + "total": 234, + "percentage": 82.47 }, "lines": { - "covered": 4438, - "total": 5740, - "percentage": 77.31 + "covered": 4465, + "total": 5780, + "percentage": 77.24 } }, "src/vs/platform/agentHost/node/agentSessionRegistry.ts": { @@ -3959,9 +3959,9 @@ "percentage": 81.02 }, "branches": { - "covered": 211, - "total": 319, - "percentage": 66.14 + "covered": 212, + "total": 320, + "percentage": 66.25 }, "functions": { "covered": 95, @@ -4949,9 +4949,9 @@ "percentage": 66.14 }, "branches": { - "covered": 411, + "covered": 410, "total": 767, - "percentage": 53.58 + "percentage": 53.45 }, "functions": { "covered": 168, @@ -5582,46 +5582,46 @@ }, "src/vs/platform/agentHost/node/copilot/copilotAgent.ts": { "statements": { - "covered": 4365, - "total": 5802, - "percentage": 75.23 + "covered": 4394, + "total": 5846, + "percentage": 75.16 }, "branches": { - "covered": 716, - "total": 1102, - "percentage": 64.97 + "covered": 723, + "total": 1116, + "percentage": 64.78 }, "functions": { - "covered": 245, - "total": 300, - "percentage": 81.66 + "covered": 247, + "total": 302, + "percentage": 81.78 }, "lines": { - "covered": 4365, - "total": 5802, - "percentage": 75.23 + "covered": 4394, + "total": 5846, + "percentage": 75.16 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts": { "statements": { - "covered": 4128, - "total": 5537, - "percentage": 74.55 + "covered": 4137, + "total": 5550, + "percentage": 74.54 }, "branches": { "covered": 740, - "total": 1074, - "percentage": 68.9 + "total": 1077, + "percentage": 68.7 }, "functions": { - "covered": 174, - "total": 215, - "percentage": 80.93 + "covered": 175, + "total": 216, + "percentage": 81.01 }, "lines": { - "covered": 4128, - "total": 5537, - "percentage": 74.55 + "covered": 4137, + "total": 5550, + "percentage": 74.54 } }, "src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts": { @@ -5983,9 +5983,9 @@ "percentage": 80.34 }, "branches": { - "covered": 164, - "total": 277, - "percentage": 59.2 + "covered": 165, + "total": 278, + "percentage": 59.35 }, "functions": { "covered": 27, @@ -6379,9 +6379,9 @@ "percentage": 100 }, "branches": { - "covered": 14, - "total": 15, - "percentage": 93.33 + "covered": 17, + "total": 18, + "percentage": 94.44 }, "functions": { "covered": 5, @@ -6462,14 +6462,14 @@ }, "src/vs/platform/agentHost/node/protocolServerHandler.ts": { "statements": { - "covered": 1603, + "covered": 1627, "total": 1830, - "percentage": 87.59 + "percentage": 88.9 }, "branches": { - "covered": 303, - "total": 380, - "percentage": 79.73 + "covered": 328, + "total": 398, + "percentage": 82.41 }, "functions": { "covered": 77, @@ -6477,9 +6477,9 @@ "percentage": 87.5 }, "lines": { - "covered": 1603, + "covered": 1627, "total": 1830, - "percentage": 87.59 + "percentage": 88.9 } }, "src/vs/platform/agentHost/node/serverUrls.ts": { @@ -6528,14 +6528,14 @@ }, "src/vs/platform/agentHost/node/sessionDatabase.ts": { "statements": { - "covered": 736, + "covered": 738, "total": 882, - "percentage": 83.44 + "percentage": 83.67 }, "branches": { - "covered": 107, - "total": 134, - "percentage": 79.85 + "covered": 113, + "total": 139, + "percentage": 81.29 }, "functions": { "covered": 39, @@ -6543,9 +6543,9 @@ "percentage": 72.22 }, "lines": { - "covered": 736, + "covered": 738, "total": 882, - "percentage": 83.44 + "percentage": 83.67 } }, "src/vs/platform/agentHost/node/sessionDiffAggregator.ts": { @@ -6731,9 +6731,9 @@ "percentage": 86.46 }, "branches": { - "covered": 58, - "total": 103, - "percentage": 56.31 + "covered": 64, + "total": 109, + "percentage": 58.71 }, "functions": { "covered": 27, @@ -6753,9 +6753,9 @@ "percentage": 83.24 }, "branches": { - "covered": 47, + "covered": 48, "total": 56, - "percentage": 83.92 + "percentage": 85.71 }, "functions": { "covered": 8, @@ -6858,14 +6858,14 @@ }, "src/vs/platform/agentHost/node/shared/fileEditTracker.ts": { "statements": { - "covered": 237, + "covered": 239, "total": 253, - "percentage": 93.67 + "percentage": 94.46 }, "branches": { "covered": 36, - "total": 43, - "percentage": 83.72 + "total": 42, + "percentage": 85.71 }, "functions": { "covered": 7, @@ -6873,9 +6873,9 @@ "percentage": 100 }, "lines": { - "covered": 237, + "covered": 239, "total": 253, - "percentage": 93.67 + "percentage": 94.46 } }, "src/vs/platform/agentHost/node/shared/loopbackProxyServer.ts": { @@ -6902,14 +6902,14 @@ }, "src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts": { "statements": { - "covered": 459, + "covered": 464, "total": 556, - "percentage": 82.55 + "percentage": 83.45 }, "branches": { - "covered": 83, + "covered": 85, "total": 103, - "percentage": 80.58 + "percentage": 82.52 }, "functions": { "covered": 25, @@ -6917,9 +6917,9 @@ "percentage": 80.64 }, "lines": { - "covered": 459, + "covered": 464, "total": 556, - "percentage": 82.55 + "percentage": 83.45 } }, "src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts": { @@ -6990,14 +6990,14 @@ }, "src/vs/platform/agentHost/node/shared/sessionServerTools.ts": { "statements": { - "covered": 1033, + "covered": 1038, "total": 1258, - "percentage": 82.11 + "percentage": 82.51 }, "branches": { - "covered": 149, - "total": 241, - "percentage": 61.82 + "covered": 155, + "total": 243, + "percentage": 63.78 }, "functions": { "covered": 48, @@ -7005,9 +7005,9 @@ "percentage": 82.75 }, "lines": { - "covered": 1033, + "covered": 1038, "total": 1258, - "percentage": 82.11 + "percentage": 82.51 } }, "src/vs/platform/agentHost/node/shared/shellCommandExecution.ts": { diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index d78293568693f8..23b99e64a02b5c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -511,15 +511,28 @@ export interface IDrivenTurnResult { } export async function driveTurnToCompletion(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): Promise { - return driveTurn(c, session, turnId, clientSeq, () => dispatchTurn(c, session, turnId, text, clientSeq)); + return driveTurn(c, buildDefaultChatUri(session), turnId, clientSeq, () => dispatchTurn(c, session, turnId, text, clientSeq)); +} + +export async function driveChatTurnToCompletion(c: TestProtocolClient, chat: string, turnId: string, text: string, clientSeq: number): Promise { + return driveTurn(c, chat, turnId, clientSeq, () => c.dispatch({ + channel: chat, + clientSeq, + action: { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: '2025-01-01T00:00:00.000Z', + message: { text, origin: { kind: MessageKind.User } }, + }, + })); } export async function driveTurnWithAttachmentsToCompletion(c: TestProtocolClient, session: string, turnId: string, text: string, attachments: readonly MessageAttachment[], clientSeq: number): Promise { - return driveTurn(c, session, turnId, clientSeq, () => dispatchTurnWithAttachments(c, session, turnId, text, attachments, clientSeq)); + return driveTurn(c, buildDefaultChatUri(session), turnId, clientSeq, () => dispatchTurnWithAttachments(c, session, turnId, text, attachments, clientSeq)); } export async function driveTurnWithModelToCompletion(c: TestProtocolClient, session: string, turnId: string, text: string, model: string, clientSeq: number): Promise { - return driveTurn(c, session, turnId, clientSeq, () => c.dispatch({ + return driveTurn(c, buildDefaultChatUri(session), turnId, clientSeq, () => c.dispatch({ channel: buildDefaultChatUri(session), clientSeq, action: { @@ -532,18 +545,17 @@ export async function driveTurnWithModelToCompletion(c: TestProtocolClient, sess } export async function driveTurnWithCancelledInputToCompletion(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): Promise { - return driveTurn(c, session, turnId, clientSeq, () => dispatchTurn(c, session, turnId, text, clientSeq), ChatInputResponseKind.Cancel); + return driveTurn(c, buildDefaultChatUri(session), turnId, clientSeq, () => dispatchTurn(c, session, turnId, text, clientSeq), ChatInputResponseKind.Cancel); } export async function driveTurnWithAnswersToCompletion(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number, getAnswers: (request: ChatInputRequest) => Record): Promise { - return driveTurn(c, session, turnId, clientSeq, () => dispatchTurn(c, session, turnId, text, clientSeq), ChatInputResponseKind.Accept, getAnswers); + return driveTurn(c, buildDefaultChatUri(session), turnId, clientSeq, () => dispatchTurn(c, session, turnId, text, clientSeq), ChatInputResponseKind.Accept, getAnswers); } -async function driveTurn(c: TestProtocolClient, session: string, turnId: string, clientSeq: number, dispatch: () => void, inputResponse = ChatInputResponseKind.Accept, answerProvider = getAcceptedAnswers): Promise { +async function driveTurn(c: TestProtocolClient, chat: string, turnId: string, clientSeq: number, dispatch: () => void, inputResponse = ChatInputResponseKind.Accept, answerProvider = getAcceptedAnswers): Promise { c.clearReceived(); dispatch(); - const chat = buildDefaultChatUri(session); const seenNotifications = new Set(); let nextClientSeq = clientSeq + 1; let sawInputRequest = false; @@ -578,7 +590,7 @@ async function driveTurn(c: TestProtocolClient, session: string, turnId: string, if (!action.confirmed) { sawPendingConfirmation = true; c.dispatch({ - channel: buildDefaultChatUri(session), + channel: chat, clientSeq: nextClientSeq++, action: { type: ActionType.ChatToolCallConfirmed, @@ -596,7 +608,7 @@ async function driveTurn(c: TestProtocolClient, session: string, turnId: string, sawInputRequest = true; const action = getActionEnvelope(notification).action as ChatInputRequestedAction; c.dispatch({ - channel: buildDefaultChatUri(session), + channel: chat, clientSeq: nextClientSeq++, action: { type: ActionType.ChatInputCompleted, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts index 61bc4a5897f74f..04ad4697500c7f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts @@ -129,6 +129,19 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): }); }); + conformanceTest(context, 'resourceRequest accepts independent read and write access requests', async function () { + await initializeClient('resource-request-modes'); + const root = createWorkspace('ahp-resource-request-modes-'); + const uri = URI.file(root).toString(); + + const [read, write] = await Promise.all([ + context.client.call('resourceRequest', { channel: ROOT_STATE_URI, uri, read: true }), + context.client.call('resourceRequest', { channel: ROOT_STATE_URI, uri, write: true }), + ]); + + assert.deepStrictEqual({ read, write }, { read: {}, write: {} }); + }); + conformanceTest(context, 'resourceList reports directory entries and their types', async function () { await initializeClient('resource-list'); const root = createWorkspace('ahp-resource-list-'); @@ -157,6 +170,25 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): assert.deepStrictEqual(result.entries, []); }); + conformanceTest(context, 'resourceRead preserves UTF-8 text and reports its content type', async function () { + await initializeClient('resource-read-unicode'); + const root = createWorkspace('ahp-resource-read-unicode-'); + const text = 'snowman \u2603 and smile \u{1F600}'; + writeFileSync(join(root, 'unicode.txt'), text); + + const result = await context.client.call('resourceRead', { + channel: ROOT_STATE_URI, + uri: fileUri(root, 'unicode.txt'), + encoding: ContentEncoding.Utf8, + }); + + assert.deepStrictEqual(result, { + data: text, + encoding: ContentEncoding.Utf8, + contentType: 'text/plain', + }); + }); + conformanceTest(context, 'resourceWrite truncates an existing file by default', async function () { await initializeClient('resource-write-default-truncate'); const root = createWorkspace('ahp-resource-write-default-truncate-'); @@ -168,6 +200,37 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): assert.strictEqual(readFileSync(join(root, 'replace.txt'), 'utf8'), 'short'); }); + conformanceTest(context, 'resourceWrite creates an empty file from empty content', async function () { + await initializeClient('resource-write-empty'); + const root = createWorkspace('ahp-resource-write-empty-'); + const file = fileUri(root, 'empty.txt'); + + await writeText(file, ''); + + assert.deepStrictEqual({ + exists: existsSync(join(root, 'empty.txt')), + content: readFileSync(join(root, 'empty.txt'), 'utf8'), + }, { + exists: true, + content: '', + }); + }); + + conformanceTest(context, 'resourceWrite preserves arbitrary base64 bytes', async function () { + await initializeClient('resource-write-binary'); + const root = createWorkspace('ahp-resource-write-binary-'); + const bytes = Buffer.from([0, 1, 2, 127, 128, 254, 255]); + + await context.client.call('resourceWrite', { + channel: ROOT_STATE_URI, + uri: fileUri(root, 'bytes.bin'), + data: bytes.toString('base64'), + encoding: ContentEncoding.Base64, + }); + + assert.deepStrictEqual([...readFileSync(join(root, 'bytes.bin'))], [...bytes]); + }); + conformanceTest(context, 'resourceDelete removes an empty directory without recursive mode', async function () { await initializeClient('resource-delete-empty-directory'); const root = createWorkspace('ahp-resource-delete-empty-directory-'); @@ -293,6 +356,134 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): }); }); + conformanceTest(context, 'equivalent resource watch descriptors produce equivalent snapshots', async function () { + await initializeClient('resource-watch-stable-channel'); + const root = createWorkspace('ahp-resource-watch-stable-channel-'); + const params = { + channel: ROOT_STATE_URI, + uri: URI.file(root).toString(), + recursive: true, + excludes: { items: ['**/*.tmp'] }, + } as const; + + const first = await context.client.call('createResourceWatch', params); + const second = await context.client.call('createResourceWatch', params); + const firstSubscription = await context.client.call('subscribe', { channel: first.channel }); + const secondSubscription = second.channel === first.channel + ? firstSubscription + : await context.client.call('subscribe', { channel: second.channel }); + + assert.deepStrictEqual(secondSubscription.snapshot?.state, firstSubscription.snapshot?.state); + }); + + conformanceTest(context, 'different resource watch descriptors produce different channels', async function () { + await initializeClient('resource-watch-distinct-channel'); + const root = createWorkspace('ahp-resource-watch-distinct-channel-'); + const rootUri = URI.file(root).toString(); + + const shallow = await context.client.call('createResourceWatch', { + channel: ROOT_STATE_URI, + uri: rootUri, + recursive: false, + }); + const recursive = await context.client.call('createResourceWatch', { + channel: ROOT_STATE_URI, + uri: rootUri, + recursive: true, + }); + + assert.notStrictEqual(recursive.channel, shallow.channel); + }); + + conformanceTest(context, 'multiple clients can subscribe to the same resource watch', async function () { + await initializeClient('resource-watch-multi-client'); + const root = createWorkspace('ahp-resource-watch-multi-client-'); + const watch = await context.client.call('createResourceWatch', { + channel: ROOT_STATE_URI, + uri: URI.file(root).toString(), + recursive: true, + }); + const client = await context.connectClient(); + try { + await client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId: `resource-watch-multi-client-extra-${config.provider}`, + }); + const [first, second] = await Promise.all([ + context.client.call('subscribe', { channel: watch.channel }), + client.call('subscribe', { channel: watch.channel }), + ]); + + assert.deepStrictEqual(second.snapshot?.state, first.snapshot?.state); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'resource watch subscribe snapshot identifies its channel and sequence', async function () { + await initializeClient('resource-watch-snapshot-identity'); + const root = createWorkspace('ahp-resource-watch-snapshot-identity-'); + const watch = await context.client.call('createResourceWatch', { + channel: ROOT_STATE_URI, + uri: URI.file(root).toString(), + recursive: false, + }); + const subscribed = await context.client.call('subscribe', { channel: watch.channel }); + + assert.deepStrictEqual({ + resource: subscribed.snapshot?.resource, + hasSequence: typeof subscribed.snapshot?.fromSeq === 'number', + }, { + resource: watch.channel, + hasSequence: true, + }); + }); + + conformanceTest(context, 'resource watch can target a regular file', async function () { + await initializeClient('resource-watch-file'); + const root = createWorkspace('ahp-resource-watch-file-'); + const file = join(root, 'watched.txt'); + writeFileSync(file, 'watched'); + const fileUriValue = URI.file(file).toString(); + + const watch = await context.client.call('createResourceWatch', { + channel: ROOT_STATE_URI, + uri: fileUriValue, + }); + const subscribed = await context.client.call('subscribe', { channel: watch.channel }); + + assert.deepStrictEqual(subscribed.snapshot?.state, { + root: fileUriValue, + recursive: false, + }); + }); + + conformanceTest(context, 'resource watch defaults recursive mode to false', async function () { + await initializeClient('resource-watch-default-recursive'); + const root = createWorkspace('ahp-resource-watch-default-recursive-'); + const rootUri = URI.file(root).toString(); + + const watch = await context.client.call('createResourceWatch', { + channel: ROOT_STATE_URI, + uri: rootUri, + }); + const subscribed = await context.client.call('subscribe', { channel: watch.channel }); + + assert.deepStrictEqual(subscribed.snapshot?.state, { + root: rootUri, + recursive: false, + }); + }); + + conformanceTest(context, 'subscribing to a malformed resource watch channel is rejected', async function () { + await initializeClient('resource-watch-malformed'); + + await assert.rejects(context.client.call('subscribe', { + channel: 'ahp-resource-watch:/not-a-valid-descriptor', + })); + }); + conformanceTest(context, 'creating a resource watch for a missing root is rejected', async function () { await initializeClient('resource-watch-missing'); const root = createWorkspace('ahp-resource-watch-missing-'); @@ -348,6 +539,39 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): assert.strictEqual(readFileSync(join(root, 'truncate.txt'), 'utf8'), 'PREFIX-NEW'); }); + conformanceTest(context, 'resourceWrite insert positions count UTF-8 bytes', async function () { + await initializeClient('resource-insert-utf8'); + const root = createWorkspace('ahp-resource-insert-utf8-'); + const file = fileUri(root, 'insert-utf8.txt'); + writeFileSync(join(root, 'insert-utf8.txt'), 'A\u{1F600}B'); + + await writeText(file, 'Z', { mode: ResourceWriteMode.Insert, position: 5 }); + + assert.strictEqual(readFileSync(join(root, 'insert-utf8.txt'), 'utf8'), 'A\u{1F600}ZB'); + }); + + conformanceTest(context, 'resourceWrite append positions count UTF-8 bytes from EOF', async function () { + await initializeClient('resource-append-utf8'); + const root = createWorkspace('ahp-resource-append-utf8-'); + const file = fileUri(root, 'append-utf8.txt'); + writeFileSync(join(root, 'append-utf8.txt'), 'A\u{1F600}B'); + + await writeText(file, 'Z', { mode: ResourceWriteMode.Append, position: 1 }); + + assert.strictEqual(readFileSync(join(root, 'append-utf8.txt'), 'utf8'), 'A\u{1F600}ZB'); + }); + + conformanceTest(context, 'resourceWrite truncate positions count UTF-8 bytes', async function () { + await initializeClient('resource-truncate-utf8'); + const root = createWorkspace('ahp-resource-truncate-utf8-'); + const file = fileUri(root, 'truncate-utf8.txt'); + writeFileSync(join(root, 'truncate-utf8.txt'), 'A\u{1F600}B'); + + await writeText(file, 'Z', { mode: ResourceWriteMode.Truncate, position: 5 }); + + assert.strictEqual(readFileSync(join(root, 'truncate-utf8.txt'), 'utf8'), 'A\u{1F600}Z'); + }); + conformanceTest(context, 'resourceWrite createOnly rejects an existing file', async function () { await initializeClient('resource-create-only'); const root = createWorkspace('ahp-resource-create-only-'); @@ -358,6 +582,45 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): assert.strictEqual(readFileSync(join(root, 'existing.txt'), 'utf8'), 'original'); }); + conformanceTest(context, 'concurrent resourceWrite createOnly calls have a single winner', async function () { + await initializeClient('resource-create-only-concurrent'); + const root = createWorkspace('ahp-resource-create-only-concurrent-'); + const file = fileUri(root, 'winner.txt'); + + const results = await Promise.allSettled([ + writeText(file, 'first', { createOnly: true }), + writeText(file, 'second', { createOnly: true }), + ]); + const content = readFileSync(join(root, 'winner.txt'), 'utf8'); + const rejected = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + + assert.deepStrictEqual({ + fulfilled: results.filter(result => result.status === 'fulfilled').length, + rejected: results.filter(result => result.status === 'rejected').length, + errorCode: rejected?.reason instanceof Error ? Reflect.get(rejected.reason, 'code') : undefined, + validContent: content === 'first' || content === 'second', + }, { + fulfilled: 1, + rejected: 1, + errorCode: AhpErrorCodes.AlreadyExists, + validContent: true, + }); + }); + + conformanceTest(context, 'resourceList preserves Unicode entry names', async function () { + await initializeClient('resource-list-unicode'); + const root = createWorkspace('ahp-resource-list-unicode-'); + const name = 'smile-\u{1F600}.txt'; + writeFileSync(join(root, name), 'unicode'); + + const result = await context.client.call('resourceList', { + channel: ROOT_STATE_URI, + uri: URI.file(root).toString(), + }); + + assert.deepStrictEqual(result.entries, [{ name, type: ResourceType.File }]); + }); + conformanceTest(context, 'resourceWrite ifMatch rejects a stale etag', async function () { await initializeClient('resource-if-match'); const root = createWorkspace('ahp-resource-if-match-'); @@ -376,6 +639,88 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): assert.strictEqual(readFileSync(join(root, 'etag.txt'), 'utf8'), 'first'); }); + conformanceTest(context, 'resourceResolve returns a stable etag without mutation', async function () { + await initializeClient('resource-etag-stable'); + const root = createWorkspace('ahp-resource-etag-stable-'); + const file = fileUri(root, 'stable.txt'); + writeFileSync(join(root, 'stable.txt'), 'stable'); + + const first = await context.client.call('resourceResolve', { channel: ROOT_STATE_URI, uri: file }); + const second = await context.client.call('resourceResolve', { channel: ROOT_STATE_URI, uri: file }); + + assert.deepStrictEqual({ + hasEtag: typeof first.etag === 'string', + stable: second.etag === first.etag, + }, { + hasEtag: true, + stable: true, + }); + }); + + conformanceTest(context, 'resourceResolve changes the etag after content changes', async function () { + await initializeClient('resource-etag-change'); + const root = createWorkspace('ahp-resource-etag-change-'); + const file = fileUri(root, 'changing.txt'); + writeFileSync(join(root, 'changing.txt'), 'before'); + const before = await context.client.call('resourceResolve', { channel: ROOT_STATE_URI, uri: file }); + + await writeText(file, 'after-with-a-different-size'); + const after = await context.client.call('resourceResolve', { channel: ROOT_STATE_URI, uri: file }); + + assert.deepStrictEqual({ + hasEtags: typeof before.etag === 'string' && typeof after.etag === 'string', + changed: before.etag !== after.etag, + }, { + hasEtags: true, + changed: true, + }); + }); + + conformanceTest(context, 'resourceWrite enforces ifMatch in append mode', async function () { + await initializeClient('resource-if-match-append'); + const root = createWorkspace('ahp-resource-if-match-append-'); + const file = fileUri(root, 'append.txt'); + writeFileSync(join(root, 'append.txt'), 'before'); + const resolved = await context.client.call('resourceResolve', { channel: ROOT_STATE_URI, uri: file }); + assert.ok(resolved.etag); + await writeText(file, 'changed'); + + await assert.rejects( + writeText(file, '-after', { mode: ResourceWriteMode.Append, ifMatch: resolved.etag }), + { code: AhpErrorCodes.Conflict }, + ); + + assert.strictEqual(readFileSync(join(root, 'append.txt'), 'utf8'), 'changed'); + }); + + conformanceTest(context, 'concurrent ifMatch writes allow only one winner', async function () { + await initializeClient('resource-if-match-concurrent'); + const root = createWorkspace('ahp-resource-if-match-concurrent-'); + const file = fileUri(root, 'concurrent.txt'); + writeFileSync(join(root, 'concurrent.txt'), 'seed'); + const resolved = await context.client.call('resourceResolve', { channel: ROOT_STATE_URI, uri: file }); + assert.ok(resolved.etag); + + const results = await Promise.allSettled([ + writeText(file, 'first-winner', { ifMatch: resolved.etag }), + writeText(file, 'second-winner', { ifMatch: resolved.etag }), + ]); + const content = readFileSync(join(root, 'concurrent.txt'), 'utf8'); + const rejected = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + + assert.deepStrictEqual({ + fulfilled: results.filter(result => result.status === 'fulfilled').length, + rejected: results.filter(result => result.status === 'rejected').length, + errorCode: rejected?.reason instanceof Error ? Reflect.get(rejected.reason, 'code') : undefined, + validContent: content === 'first-winner' || content === 'second-winner', + }, { + fulfilled: 1, + rejected: 1, + errorCode: AhpErrorCodes.Conflict, + validContent: true, + }); + }); + conformanceTest(context, 'resourceCopy failIfExists preserves the destination', async function () { await initializeClient('resource-copy-conflict'); const root = createWorkspace('ahp-resource-copy-conflict-'); @@ -391,6 +736,30 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): assert.strictEqual(readFileSync(join(root, 'destination.txt'), 'utf8'), 'destination'); }); + conformanceTest(context, 'resourceCopy failIfExists preserves both directory trees', async function () { + await initializeClient('resource-copy-directory-conflict'); + const root = createWorkspace('ahp-resource-copy-directory-conflict-'); + mkdirSync(join(root, 'source')); + mkdirSync(join(root, 'destination')); + writeFileSync(join(root, 'source', 'source.txt'), 'source'); + writeFileSync(join(root, 'destination', 'destination.txt'), 'destination'); + + await assert.rejects(context.client.call('resourceCopy', { + channel: ROOT_STATE_URI, + source: fileUri(root, 'source'), + destination: fileUri(root, 'destination'), + failIfExists: true, + }), { code: AhpErrorCodes.AlreadyExists }); + + assert.deepStrictEqual({ + source: readFileSync(join(root, 'source', 'source.txt'), 'utf8'), + destination: readFileSync(join(root, 'destination', 'destination.txt'), 'utf8'), + }, { + source: 'source', + destination: 'destination', + }); + }); + conformanceTest(context, 'resourceMove failIfExists preserves both files', async function () { await initializeClient('resource-move-conflict'); const root = createWorkspace('ahp-resource-move-conflict-'); @@ -412,6 +781,30 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): }); }); + conformanceTest(context, 'resourceMove failIfExists preserves both directory trees', async function () { + await initializeClient('resource-move-directory-conflict'); + const root = createWorkspace('ahp-resource-move-directory-conflict-'); + mkdirSync(join(root, 'source')); + mkdirSync(join(root, 'destination')); + writeFileSync(join(root, 'source', 'source.txt'), 'source'); + writeFileSync(join(root, 'destination', 'destination.txt'), 'destination'); + + await assert.rejects(context.client.call('resourceMove', { + channel: ROOT_STATE_URI, + source: fileUri(root, 'source'), + destination: fileUri(root, 'destination'), + failIfExists: true, + }), { code: AhpErrorCodes.AlreadyExists }); + + assert.deepStrictEqual({ + source: readFileSync(join(root, 'source', 'source.txt'), 'utf8'), + destination: readFileSync(join(root, 'destination', 'destination.txt'), 'utf8'), + }, { + source: 'source', + destination: 'destination', + }); + }); + conformanceTest(context, 'resourceMkdir rejects a path occupied by a file', async function () { await initializeClient('resource-mkdir-file'); const root = createWorkspace('ahp-resource-mkdir-file-'); @@ -424,6 +817,57 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): }), { code: AhpErrorCodes.AlreadyExists }); }); + conformanceTest(context, 'resourceMkdir creates missing parent directories', async function () { + await initializeClient('resource-mkdir-parents'); + const root = createWorkspace('ahp-resource-mkdir-parents-'); + const nested = join(root, 'one', 'two', 'three'); + + await context.client.call('resourceMkdir', { + channel: ROOT_STATE_URI, + uri: URI.file(nested).toString(), + }); + + assert.strictEqual(existsSync(nested), true); + }); + + conformanceTest(context, 'resourceResolve reports directory metadata', async function () { + await initializeClient('resource-resolve-directory'); + const root = createWorkspace('ahp-resource-resolve-directory-'); + const nested = join(root, 'directory'); + mkdirSync(nested); + + const result = await context.client.call('resourceResolve', { + channel: ROOT_STATE_URI, + uri: URI.file(nested).toString(), + }); + + assert.deepStrictEqual({ + uri: result.uri, + type: result.type, + hasMtime: typeof result.mtime === 'string', + hasCtime: typeof result.ctime === 'string', + }, { + uri: URI.file(nested).toString(), + type: ResourceType.Directory, + hasMtime: true, + hasCtime: true, + }); + }); + + conformanceTest(context, 'resourceResolve reports binary size in bytes', async function () { + await initializeClient('resource-resolve-binary-size'); + const root = createWorkspace('ahp-resource-resolve-binary-size-'); + const bytes = Buffer.from([0, 255, 1, 254, 2]); + writeFileSync(join(root, 'size.bin'), bytes); + + const result = await context.client.call('resourceResolve', { + channel: ROOT_STATE_URI, + uri: fileUri(root, 'size.bin'), + }); + + assert.strictEqual(result.size, bytes.byteLength); + }); + conformanceTest(context, 'resourceDelete recursively removes a directory tree', async function () { await initializeClient('resource-delete-tree'); const root = createWorkspace('ahp-resource-delete-tree-'); @@ -440,6 +884,20 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): assert.strictEqual(existsSync(tree), false); }); + conformanceTest(context, 'resourceDelete removes a regular file without recursive mode', async function () { + await initializeClient('resource-delete-file'); + const root = createWorkspace('ahp-resource-delete-file-'); + const file = join(root, 'delete.txt'); + writeFileSync(file, 'delete'); + + await context.client.call('resourceDelete', { + channel: ROOT_STATE_URI, + uri: URI.file(file).toString(), + }); + + assert.strictEqual(existsSync(file), false); + }); + conformanceTest(context, 'resourceWrite decodes base64 content', async function () { await initializeClient('resource-base64'); const root = createWorkspace('ahp-resource-base64-'); @@ -633,6 +1091,21 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): assert.strictEqual(readFileSync(join(root, 'destination', 'nested', 'file.txt'), 'utf8'), 'copied'); }); + conformanceTest(context, 'resourceCopy preserves binary file bytes', async function () { + await initializeClient('resource-copy-binary'); + const root = createWorkspace('ahp-resource-copy-binary-'); + const bytes = Buffer.from([0, 255, 32, 128, 64]); + writeFileSync(join(root, 'source.bin'), bytes); + + await context.client.call('resourceCopy', { + channel: ROOT_STATE_URI, + source: fileUri(root, 'source.bin'), + destination: fileUri(root, 'copy.bin'), + }); + + assert.deepStrictEqual([...readFileSync(join(root, 'copy.bin'))], [...bytes]); + }); + conformanceTest(context, 'resourceCopy overwrites an existing destination by default', async function () { await initializeClient('resource-copy-overwrite'); const root = createWorkspace('ahp-resource-copy-overwrite-'); @@ -648,6 +1121,29 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): assert.strictEqual(readFileSync(join(root, 'destination.txt'), 'utf8'), 'source'); }); + conformanceTest(context, 'resourceCopy replaces an existing destination directory tree', async function () { + await initializeClient('resource-copy-directory-overwrite'); + const root = createWorkspace('ahp-resource-copy-directory-overwrite-'); + mkdirSync(join(root, 'source')); + mkdirSync(join(root, 'destination')); + writeFileSync(join(root, 'source', 'source.txt'), 'source'); + writeFileSync(join(root, 'destination', 'stale.txt'), 'stale'); + + await context.client.call('resourceCopy', { + channel: ROOT_STATE_URI, + source: fileUri(root, 'source'), + destination: fileUri(root, 'destination'), + }); + + assert.deepStrictEqual({ + source: readFileSync(join(root, 'destination', 'source.txt'), 'utf8'), + staleExists: existsSync(join(root, 'destination', 'stale.txt')), + }, { + source: 'source', + staleExists: false, + }); + }); + conformanceTest(context, 'resourceCopy reports a missing source', async function () { await initializeClient('resource-copy-missing'); const root = createWorkspace('ahp-resource-copy-missing-'); @@ -680,6 +1176,27 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): }); }); + conformanceTest(context, 'resourceMove preserves binary file bytes', async function () { + await initializeClient('resource-move-binary'); + const root = createWorkspace('ahp-resource-move-binary-'); + const bytes = Buffer.from([0, 255, 32, 128, 64]); + writeFileSync(join(root, 'source.bin'), bytes); + + await context.client.call('resourceMove', { + channel: ROOT_STATE_URI, + source: fileUri(root, 'source.bin'), + destination: fileUri(root, 'moved.bin'), + }); + + assert.deepStrictEqual({ + sourceExists: existsSync(join(root, 'source.bin')), + bytes: [...readFileSync(join(root, 'moved.bin'))], + }, { + sourceExists: false, + bytes: [...bytes], + }); + }); + conformanceTest(context, 'resourceMove overwrites an existing destination by default', async function () { await initializeClient('resource-move-overwrite'); const root = createWorkspace('ahp-resource-move-overwrite-'); @@ -701,6 +1218,31 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): }); }); + conformanceTest(context, 'resourceMove replaces an existing destination directory tree', async function () { + await initializeClient('resource-move-directory-overwrite'); + const root = createWorkspace('ahp-resource-move-directory-overwrite-'); + mkdirSync(join(root, 'source')); + mkdirSync(join(root, 'destination')); + writeFileSync(join(root, 'source', 'source.txt'), 'source'); + writeFileSync(join(root, 'destination', 'stale.txt'), 'stale'); + + await context.client.call('resourceMove', { + channel: ROOT_STATE_URI, + source: fileUri(root, 'source'), + destination: fileUri(root, 'destination'), + }); + + assert.deepStrictEqual({ + sourceExists: existsSync(join(root, 'source')), + content: readFileSync(join(root, 'destination', 'source.txt'), 'utf8'), + staleExists: existsSync(join(root, 'destination', 'stale.txt')), + }, { + sourceExists: false, + content: 'source', + staleExists: false, + }); + }); + conformanceTest(context, 'resourceMove reports a missing source', async function () { await initializeClient('resource-move-missing'); const root = createWorkspace('ahp-resource-move-missing-'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts index f4b5e406b2ac34..31d2d56054b94d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts @@ -8,10 +8,11 @@ import { execSync } from 'child_process'; import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from '../../../../../../base/common/path.js'; +import { basename } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { CompletionItemKind, type CompletionsResult, type InitializeResult, type ResolveSessionConfigResult, type SessionConfigCompletionsResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; -import { buildDefaultChatUri, ROOT_STATE_URI, ToolCallConfirmationReason, type TerminalState, type ToolResultContent } from '../../../../common/state/sessionState.js'; +import { buildDefaultChatUri, MessageAttachmentKind, ROOT_STATE_URI, ToolCallConfirmationReason, type TerminalState, type ToolResultContent } from '../../../../common/state/sessionState.js'; import { createRealSession, dispatchTurn, @@ -38,12 +39,12 @@ export function defineHostFeaturesTests(context: IAgentHostE2ETestContext): void return createRealSession(context.client, config, `${prefix}-${config.provider}`, createdSessions, URI.file(workspace)); } - async function getCompletions(sessionUri: string, text: string): Promise { + async function getCompletions(sessionUri: string, text: string, offset = text.length): Promise { return context.client.call('completions', { channel: buildDefaultChatUri(sessionUri), kind: CompletionItemKind.UserMessage, text, - offset: text.length, + offset, }); } @@ -115,6 +116,154 @@ export function defineHostFeaturesTests(context: IAgentHostE2ETestContext): void assert.deepStrictEqual(result, { items: [] }); }); + conformanceTest(context, 'workspace file completion replaces only the token before the cursor', async function () { + const workspace = createWorkspace('ahp-file-completion-range-'); + writeFileSync(join(workspace, 'alpha.ts'), 'alpha'); + const sessionUri = await createSession('file-completion-range', workspace); + const text = 'review @alp trailing'; + + const result = await getCompletions(sessionUri, text, 'review @alp'.length); + + assert.deepStrictEqual(result.items.map(item => ({ + insertText: item.insertText, + rangeStart: item.rangeStart, + rangeEnd: item.rangeEnd, + })), [{ + insertText: '@alpha.ts', + rangeStart: 'review '.length, + rangeEnd: 'review @alp'.length, + }]); + }); + + conformanceTest(context, 'workspace file completion ignores embedded at signs', async function () { + const workspace = createWorkspace('ahp-file-completion-embedded-'); + writeFileSync(join(workspace, 'example.txt'), 'example'); + const sessionUri = await createSession('file-completion-embedded', workspace); + + const result = await getCompletions(sessionUri, 'email@example'); + + assert.deepStrictEqual(result.items, []); + }); + + conformanceTest(context, 'workspace file completion accepts tab and newline delimiters', async function () { + const workspace = createWorkspace('ahp-file-completion-whitespace-'); + writeFileSync(join(workspace, 'alpha.txt'), 'alpha'); + const sessionUri = await createSession('file-completion-whitespace', workspace); + + const [tab, newline] = await Promise.all([ + getCompletions(sessionUri, 'review\t@alp'), + getCompletions(sessionUri, 'review\n#alp'), + ]); + + assert.deepStrictEqual({ + tab: tab.items.map(item => item.insertText), + newline: newline.items.map(item => item.insertText), + }, { + tab: ['@alpha.txt'], + newline: ['#alpha.txt'], + }); + }); + + conformanceTest(context, 'workspace file completion disambiguates duplicate basenames', async function () { + const workspace = createWorkspace('ahp-file-completion-duplicates-'); + mkdirSync(join(workspace, 'one')); + mkdirSync(join(workspace, 'two')); + writeFileSync(join(workspace, 'one', 'same.ts'), 'one'); + writeFileSync(join(workspace, 'two', 'same.ts'), 'two'); + const sessionUri = await createSession('file-completion-duplicates', workspace); + + const result = await getCompletions(sessionUri, '@same'); + + assert.deepStrictEqual(result.items.map(item => item.attachment?.label).sort(), [ + `${basename(URI.file(workspace))} \u2022 one/same.ts`, + `${basename(URI.file(workspace))} \u2022 two/same.ts`, + ]); + }); + + conformanceTest(context, 'workspace file completion matches nested relative paths', async function () { + const workspace = createWorkspace('ahp-file-completion-nested-'); + mkdirSync(join(workspace, 'feature')); + writeFileSync(join(workspace, 'feature', 'target.ts'), 'nested'); + writeFileSync(join(workspace, 'target.ts'), 'root'); + const sessionUri = await createSession('file-completion-nested', workspace); + + const result = await getCompletions(sessionUri, '@feature/target'); + + assert.deepStrictEqual(result.items.map(item => item.attachment?.type === MessageAttachmentKind.Resource ? item.attachment.uri : undefined), [ + URI.file(join(workspace, 'feature', 'target.ts')).toString(), + ]); + }); + + conformanceTest(context, 'workspace file completion caps an empty query at fifty results', async function () { + const workspace = createWorkspace('ahp-file-completion-limit-'); + for (let index = 0; index < 60; index++) { + writeFileSync(join(workspace, `file-${String(index).padStart(2, '0')}.txt`), String(index)); + } + const sessionUri = await createSession('file-completion-limit', workspace); + + const result = await getCompletions(sessionUri, '@'); + + assert.strictEqual(result.items.length, 50); + }); + + conformanceTest(context, 'workspace file completion supports a trigger at the start of input', async function () { + const workspace = createWorkspace('ahp-file-completion-start-'); + writeFileSync(join(workspace, 'alpha.ts'), 'alpha'); + const sessionUri = await createSession('file-completion-start', workspace); + + const result = await getCompletions(sessionUri, '#alp'); + + assert.deepStrictEqual(result.items.map(item => ({ + insertText: item.insertText, + rangeStart: item.rangeStart, + rangeEnd: item.rangeEnd, + })), [{ + insertText: '#alpha.ts', + rangeStart: 0, + rangeEnd: 4, + }]); + }); + + conformanceTest(context, 'workspace file completion ignores a token separated from the cursor', async function () { + const workspace = createWorkspace('ahp-file-completion-separated-'); + writeFileSync(join(workspace, 'alpha.ts'), 'alpha'); + const sessionUri = await createSession('file-completion-separated', workspace); + + const result = await getCompletions(sessionUri, 'review @alpha later'); + + assert.deepStrictEqual(result.items, []); + }); + + conformanceTest(context, 'workspace file completion matches file names case-insensitively', async function () { + const workspace = createWorkspace('ahp-file-completion-case-'); + writeFileSync(join(workspace, 'MixedCase.ts'), 'mixed'); + const sessionUri = await createSession('file-completion-case', workspace); + + const result = await getCompletions(sessionUri, '@mixedcase'); + + assert.deepStrictEqual(result.items.map(item => item.insertText), ['@MixedCase.ts']); + }); + + conformanceTest(context, 'workspace file completion fuzzy matches a basename', async function () { + const workspace = createWorkspace('ahp-file-completion-fuzzy-'); + writeFileSync(join(workspace, 'agentHostCoverage.ts'), 'coverage'); + const sessionUri = await createSession('file-completion-fuzzy', workspace); + + const result = await getCompletions(sessionUri, '@agcov'); + + assert.deepStrictEqual(result.items.map(item => item.insertText), ['@agentHostCoverage.ts']); + }); + + conformanceTest(context, 'workspace file completion ignores a token after the cursor', async function () { + const workspace = createWorkspace('ahp-file-completion-offset-'); + writeFileSync(join(workspace, 'alpha.ts'), 'alpha'); + const sessionUri = await createSession('file-completion-offset', workspace); + + const result = await getCompletions(sessionUri, 'prefix @alpha', 'prefix'.length); + + assert.deepStrictEqual(result.items, []); + }); + conformanceTest(context, 'rename completion appears after a locally renamed turn', async function () { const sessionUri = await createSession('rename-completion'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/protocolContractsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/protocolContractsSuite.ts index 226e185858d352..b7139239892946 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/protocolContractsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/protocolContractsSuite.ts @@ -72,6 +72,18 @@ export function defineProtocolContractTests(context: IAgentHostE2ETestContext): return client; } + async function triggerServerActivity(client: TestProtocolClient, marker: string): Promise { + await client.call('createSession', { + channel: `missing-provider:/${marker}`, + provider: 'missing-provider', + }).catch(() => undefined); + await client.call('ping', { channel: ROOT_STATE_URI }); + } + + function isOtlpExport(notification: { readonly method: string }): boolean { + return notification.method === 'otlp/exportLogs'; + } + conformanceTest(context, 'ping answers while the connection is live', async function () { // Liveness has no payload — the response itself is the signal, so the // contract is that the call resolves rather than what it returns. @@ -113,6 +125,193 @@ export function defineProtocolContractTests(context: IAgentHostE2ETestContext): } }); + conformanceTest(context, 'initialize advertises the OTLP log channel template', async function () { + const client = await context.connectClient(); + try { + const result = await client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId: `otlp-capability-${config.provider}`, + }); + + assert.deepStrictEqual(result.telemetry, { logs: 'ahp-otlp://logs/{level}' }); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'initialize ignores an unknown OTLP log level', async function () { + const client = await context.connectClient(); + try { + const result = await client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId: `otlp-invalid-initial-${config.provider}`, + initialSubscriptions: ['ahp-otlp://logs/verbose'], + }); + client.clearReceived(); + + await triggerServerActivity(client, 'otlp-invalid-initial'); + + assert.deepStrictEqual({ + snapshots: result.snapshots, + receivedExports: client.receivedNotifications(isOtlpExport).length, + }, { + snapshots: [], + receivedExports: 0, + }); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'subscribe acknowledges an unknown OTLP log level without installing it', async function () { + const client = await initializeAdditionalClient('otlp-invalid-subscribe'); + try { + const result = await client.call('subscribe', { channel: 'ahp-otlp://logs/verbose' }); + client.clearReceived(); + + await triggerServerActivity(client, 'otlp-invalid-subscribe'); + + assert.deepStrictEqual({ + result, + receivedExports: client.receivedNotifications(isOtlpExport).length, + }, { + result: {}, + receivedExports: 0, + }); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'OTLP log subscriptions route exports on their canonical channel', async function () { + const client = await initializeAdditionalClient('otlp-canonical'); + try { + await client.call('subscribe', { channel: 'ahp-otlp://logs/trace' }); + const exported = client.waitForNotification(isOtlpExport, 30_000); + + await triggerServerActivity(client, 'otlp-canonical'); + const notification = await exported; + + assert.strictEqual((notification.params as OtlpExportLogsParams).channel, 'ahp-otlp://logs/trace'); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'OTLP log delivery resumes after unsubscribe and resubscribe', async function () { + const client = await initializeAdditionalClient('otlp-resubscribe'); + try { + await client.call('subscribe', { channel: 'ahp-otlp://logs/trace' }); + client.notify('unsubscribe', { channel: 'ahp-otlp://logs/trace' }); + await client.call('ping', { channel: ROOT_STATE_URI }); + await client.call('subscribe', { channel: 'ahp-otlp://logs/trace' }); + client.clearReceived(); + const exported = client.waitForNotification(isOtlpExport, 30_000); + + await triggerServerActivity(client, 'otlp-resubscribe'); + + assert.strictEqual((await exported).method, 'otlp/exportLogs'); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'unsubscribing from OTLP logs stops delivery', async function () { + const client = await initializeAdditionalClient('otlp-unsubscribe'); + try { + await client.call('subscribe', { channel: 'ahp-otlp://logs/trace' }); + client.notify('unsubscribe', { channel: 'ahp-otlp://logs/trace' }); + await client.call('ping', { channel: ROOT_STATE_URI }); + client.clearReceived(); + + await triggerServerActivity(client, 'otlp-unsubscribe'); + + assert.strictEqual(client.receivedNotifications(isOtlpExport).length, 0); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'info-level OTLP subscriptions receive server activity', async function () { + const client = await initializeAdditionalClient('otlp-info-level'); + try { + await client.call('subscribe', { channel: 'ahp-otlp://logs/info' }); + const exported = client.waitForNotification(isOtlpExport, 30_000); + + await triggerServerActivity(client, 'otlp-info-level'); + + assert.strictEqual((await exported).method, 'otlp/exportLogs'); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'fatal-level OTLP subscriptions filter ordinary server activity', async function () { + const client = await initializeAdditionalClient('otlp-fatal-level'); + try { + await client.call('subscribe', { channel: 'ahp-otlp://logs/fatal' }); + client.clearReceived(); + + await triggerServerActivity(client, 'otlp-fatal-level'); + + assert.strictEqual(client.receivedNotifications(isOtlpExport).length, 0); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'OTLP logs fan out to every subscribed client', async function () { + const first = await initializeAdditionalClient('otlp-fanout-first'); + const second = await initializeAdditionalClient('otlp-fanout-second'); + try { + await second.call('subscribe', { channel: 'ahp-otlp://logs/trace' }); + await first.call('subscribe', { channel: 'ahp-otlp://logs/trace' }); + await Promise.all([ + first.call('ping', { channel: ROOT_STATE_URI }), + second.call('ping', { channel: ROOT_STATE_URI }), + ]); + first.clearReceived(); + second.clearReceived(); + const firstExport = first.waitForNotification(isOtlpExport, 30_000); + const secondExport = second.waitForNotification(isOtlpExport, 30_000); + + await triggerServerActivity(first, 'otlp-fanout'); + + assert.deepStrictEqual([(await firstExport).method, (await secondExport).method], ['otlp/exportLogs', 'otlp/exportLogs']); + } finally { + first.close(); + second.close(); + } + }); + + conformanceTest(context, 'initialize installs an OTLP log subscription without a snapshot', async function () { + const client = await context.connectClient(); + try { + const initialized = await client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId: `otlp-initial-${config.provider}`, + initialSubscriptions: ['ahp-otlp://logs/trace'], + }); + const exported = client.waitForNotification(n => n.method === 'otlp/exportLogs', 30_000); + + await client.call('createSession', { channel: 'missing-provider:/otlp-initial', provider: 'missing-provider' }).catch(() => undefined); + const notification = await exported; + + assert.deepStrictEqual({ + snapshots: initialized.snapshots, + channel: (notification.params as OtlpExportLogsParams).channel, + }, { + snapshots: [], + channel: 'ahp-otlp://logs/trace', + }); + } finally { + client.close(); + } + }); + conformanceTest(context, 'management diagnostics report providers and network endpoints', async function () { const client = await context.connectClient(); try { @@ -232,6 +431,69 @@ export function defineProtocolContractTests(context: IAgentHostE2ETestContext): } }); + conformanceTest(context, 'initialize selects a supported fallback protocol version', async function () { + const client = await context.connectClient(); + try { + const result = await client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: ['999.0.0', PROTOCOL_VERSION], + clientId: `fallback-version-${config.provider}`, + }); + + assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'initialize accepts informational client identity', async function () { + const client = await context.connectClient(); + try { + const result = await client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId: `client-info-${config.provider}`, + clientInfo: { name: 'agent-host-e2e', version: '1.2.3', title: 'Agent Host E2E' }, + }); + + assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'initialize accepts locale metadata', async function () { + const client = await context.connectClient(); + try { + const result = await client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId: `locale-${config.provider}`, + locale: 'ja-JP', + }); + + assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); + } finally { + client.close(); + } + }); + + conformanceTest(context, 'initialize accepts declared client capabilities', async function () { + const client = await context.connectClient(); + try { + const result = await client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId: `capabilities-${config.provider}`, + capabilities: { mcpApps: {} }, + }); + + assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); + } finally { + client.close(); + } + }); + conformanceTest(context, 'initialize cannot be repeated after the handshake', async function () { const client = await initializeAdditionalClient('repeat-initialize'); try { @@ -670,6 +932,172 @@ export function defineProtocolContractTests(context: IAgentHostE2ETestContext): } }); + conformanceTest(context, 'reconnect with no missed actions returns an empty replay', async function () { + const { sessionUri } = await createSession('reconnect-empty'); + const chatUri = buildDefaultChatUri(sessionUri); + const droppedClientId = `reconnect-empty-${config.provider}`; + const { carried: seenThrough, revived } = await afterConnectionDrop(droppedClientId, async first => { + const subscribed = await first.call('subscribe', { channel: chatUri }); + return subscribed.snapshot!.fromSeq; + }); + + try { + const result = await revived.call('reconnect', { + channel: ROOT_STATE_URI, + clientId: droppedClientId, + lastSeenServerSeq: seenThrough, + subscriptions: [chatUri], + }); + + assert.deepStrictEqual(result, { type: ReconnectResultType.Replay, actions: [], missing: [] }); + } finally { + revived.close(); + } + }); + + conformanceTest(context, 'reconnect excludes actions from channels the client did not restore', async function () { + const firstSession = await createSession('reconnect-filter-first'); + const secondWorkspace = mkdtempSync(join(tmpdir(), 'ahp-reconnect-filter-second-')); + tempDirs.push(secondWorkspace); + const secondSessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); + await context.client.call('createSession', { + channel: secondSessionUri, + provider: config.provider, + workingDirectories: [URI.file(secondWorkspace).toString()], + config: { isolation: 'folder' }, + }); + createdSessions.push(secondSessionUri); + await context.client.call('subscribe', { channel: secondSessionUri }); + const firstChat = buildDefaultChatUri(firstSession.sessionUri); + const secondChat = buildDefaultChatUri(secondSessionUri); + await context.client.call('subscribe', { channel: secondChat }); + const droppedClientId = `reconnect-filter-${config.provider}`; + + const { carried: seenThrough, revived } = await afterConnectionDrop(droppedClientId, async first => { + const subscribed = await first.call('subscribe', { channel: firstChat }); + return subscribed.snapshot!.fromSeq; + }); + + try { + await dispatchAndWaitOnShared(firstChat, { type: ActionType.ChatDraftChanged, draft: { text: 'included', origin: { kind: MessageKind.User } } }); + await dispatchAndWaitOnShared(secondChat, { type: ActionType.ChatDraftChanged, draft: { text: 'excluded', origin: { kind: MessageKind.User } } }); + + const result = await revived.call('reconnect', { + channel: ROOT_STATE_URI, + clientId: droppedClientId, + lastSeenServerSeq: seenThrough, + subscriptions: [firstChat], + }); + + assert.deepStrictEqual(result.type === ReconnectResultType.Replay + ? result.actions.map(action => ({ channel: action.channel, type: action.action.type })) + : result.type, [{ channel: firstChat, type: ActionType.ChatDraftChanged }]); + } finally { + revived.close(); + } + }); + + conformanceTest(context, 'reconnect restores an OTLP log subscription', async function () { + const droppedClientId = `reconnect-otlp-${config.provider}`; + const { carried: seenThrough, revived } = await afterConnectionDrop(droppedClientId, async first => { + const root = await first.call('subscribe', { channel: ROOT_STATE_URI }); + await first.call('subscribe', { channel: 'ahp-otlp://logs/trace' }); + return root.snapshot!.fromSeq; + }); + + try { + await revived.call('reconnect', { + channel: ROOT_STATE_URI, + clientId: droppedClientId, + lastSeenServerSeq: seenThrough, + subscriptions: ['ahp-otlp://logs/trace'], + }); + const exported = revived.waitForNotification(n => n.method === 'otlp/exportLogs', 30_000); + + await context.client.call('createSession', { channel: 'missing-provider:/otlp-reconnect', provider: 'missing-provider' }).catch(() => undefined); + + assert.strictEqual((await exported).method, 'otlp/exportLogs'); + } finally { + revived.close(); + } + }); + + conformanceTest(context, 'reconnect replays missed session and chat actions together', async function () { + const { sessionUri } = await createSession('reconnect-state-snapshots'); + const chatUri = buildDefaultChatUri(sessionUri); + const droppedClientId = `reconnect-state-snapshots-${config.provider}`; + const { carried: seenThrough, revived } = await afterConnectionDrop(droppedClientId, async first => { + const session = await first.call('subscribe', { channel: sessionUri }); + const chat = await first.call('subscribe', { channel: chatUri }); + return Math.max(session.snapshot!.fromSeq, chat.snapshot!.fromSeq); + }); + + try { + await dispatchAndWaitOnShared(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Replay Session' }); + await dispatchAndWaitOnShared(chatUri, { type: ActionType.ChatDraftChanged, draft: { text: 'replay draft', origin: { kind: MessageKind.User } } }); + + const result = await revived.call('reconnect', { + channel: ROOT_STATE_URI, + clientId: droppedClientId, + lastSeenServerSeq: seenThrough, + subscriptions: [sessionUri, chatUri], + }); + + assert.deepStrictEqual(result.type === ReconnectResultType.Replay + ? result.actions.map(action => ({ channel: action.channel, type: action.action.type })) + : result.type, [ + { channel: sessionUri, type: ActionType.SessionTitleChanged }, + { channel: chatUri, type: ActionType.ChatDraftChanged }, + ]); + } finally { + revived.close(); + } + }); + + conformanceTest(context, 'reconnected state subscriptions receive subsequent live actions', async function () { + const { sessionUri } = await createSession('reconnect-live'); + const chatUri = buildDefaultChatUri(sessionUri); + const droppedClientId = `reconnect-live-${config.provider}`; + const { carried: seenThrough, revived } = await afterConnectionDrop(droppedClientId, async first => { + const session = await first.call('subscribe', { channel: sessionUri }); + const chat = await first.call('subscribe', { channel: chatUri }); + return Math.max(session.snapshot!.fromSeq, chat.snapshot!.fromSeq); + }); + + try { + await revived.call('reconnect', { + channel: ROOT_STATE_URI, + clientId: droppedClientId, + lastSeenServerSeq: seenThrough, + subscriptions: [sessionUri, chatUri], + }); + const sessionChanged = revived.waitForNotification(n => + isActionNotification(n, 'session/titleChanged') && getActionEnvelope(n).channel === sessionUri, + ); + const chatChanged = revived.waitForNotification(n => + isActionNotification(n, 'chat/draftChanged') && getActionEnvelope(n).channel === chatUri, + ); + + context.client.dispatch({ + channel: sessionUri, + clientSeq: nextClientSeq(), + action: { type: ActionType.SessionTitleChanged, title: 'Reconnected Live' }, + }); + context.client.dispatch({ + channel: chatUri, + clientSeq: nextClientSeq(), + action: { type: ActionType.ChatDraftChanged, draft: { text: 'live', origin: { kind: MessageKind.User } } }, + }); + + assert.deepStrictEqual([ + getActionEnvelope(await sessionChanged).action.type, + getActionEnvelope(await chatChanged).action.type, + ], [ActionType.SessionTitleChanged, ActionType.ChatDraftChanged]); + } finally { + revived.close(); + } + }); + conformanceTest(context, 'reconnect reports a subscription it cannot resume as missing', async function () { const { sessionUri } = await createSession('reconnect-missing'); const chatUri = buildDefaultChatUri(sessionUri); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts index 117e3936255b7e..842631f16c3f59 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts @@ -25,7 +25,7 @@ import { type SessionState, } from '../../../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; -import { createRealSession, driveTurnToCompletion, resolveGitHubToken, textFromContent } from '../harness/agentHostE2ETestHarness.js'; +import { createRealSession, driveChatTurnToCompletion, driveTurnToCompletion, resolveGitHubToken, textFromContent } from '../harness/agentHostE2ETestHarness.js'; import { summarizeAnthropicRequest } from '../harness/capiWireCodec.js'; import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; @@ -200,7 +200,7 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void toolName: string, options: { readonly success?: boolean; readonly result?: readonly RegExp[] } = {}, ): Promise<{ readonly turn: Awaited>; readonly tool: IObservedToolCall }> { - const turn = await driveTurnToCompletion(context.client, session.sessionUri, turnId, prompt, reserveClientSequenceBlock()); + const turn = await driveChatTurnToCompletion(context.client, session.chatUri, turnId, prompt, reserveClientSequenceBlock()); const starts = context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallStart')) .map(n => ({ envelope: getActionEnvelope(n), action: getActionEnvelope(n).action as ChatToolCallStartAction })) .filter(({ envelope, action }) => envelope.channel === session.chatUri && action.turnId === turnId && toolNameMatches(action.toolName, toolName)); @@ -498,6 +498,55 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void assert.deepStrictEqual(result.sessions.find(item => item.session === archived.sessionUri)?.status?.split(',').sort(), ['archived', 'idle']); }); + serverToolTest('server tool: list_sessions hides archived sessions by default', async function () { + const session = await createSession('sessions-hide-archived'); + const archived = await addSession('sessions-hide-archived-target', session.workspace); + await materializeSession(archived, 'turn-sessions-hide-archived-target', 'ARCHIVED_HIDDEN_READY'); + await dispatchAndWait(archived.sessionUri, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + context.client.clearReceived(); + + const { tool } = await driveServerTool( + session, + 'turn-sessions-hide-archived', + `Call list_sessions exactly once with workspace "${session.workspace}", then reply exactly "listed".`, + SessionServerToolName.ListSessions, + ); + const result = JSON.parse(tool.resultText) as { sessions: readonly { session: string }[] }; + + assert.deepStrictEqual({ + includesActive: result.sessions.some(item => item.session === session.sessionUri), + includesArchived: result.sessions.some(item => item.session === archived.sessionUri), + }, { + includesActive: true, + includesArchived: false, + }); + }); + + serverToolTest('server tool: list_sessions includeArchived returns active and archived sessions', async function () { + const session = await createSession('sessions-include-archived'); + const archived = await addSession('sessions-include-archived-target', session.workspace); + await materializeSession(archived, 'turn-sessions-include-archived-target', 'ARCHIVED_INCLUDED_READY'); + await dispatchAndWait(archived.sessionUri, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + context.client.clearReceived(); + + const { tool } = await driveServerTool( + session, + 'turn-sessions-include-archived', + `Call list_sessions exactly once with workspace "${session.workspace}" and includeArchived true, then reply exactly "listed".`, + SessionServerToolName.ListSessions, + ); + const result = JSON.parse(tool.resultText) as { sessions: readonly { session: string }[] }; + const returned = new Set(result.sessions.map(item => item.session)); + + assert.deepStrictEqual({ + includesActive: returned.has(session.sessionUri), + includesArchived: returned.has(archived.sessionUri), + }, { + includesActive: true, + includesArchived: true, + }); + }); + serverToolTest('server tool: list_sessions status filter finds the invoking in-progress session', async function () { const session = await createSession('sessions-status'); const { tool } = await driveServerTool( @@ -513,6 +562,31 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void }]); }); + serverToolTest('server tool: list_sessions status filter combines active and archived sessions', async function () { + const session = await createSession('sessions-status-combined'); + const archived = await addSession('sessions-status-combined-target', session.workspace); + await materializeSession(archived, 'turn-sessions-status-combined-target', 'COMBINED_TARGET_READY'); + await dispatchAndWait(archived.sessionUri, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + context.client.clearReceived(); + + const { tool } = await driveServerTool( + session, + 'turn-sessions-status-combined', + 'Call list_sessions exactly once with status ["inProgress", "archived"], then reply exactly "filtered".', + SessionServerToolName.ListSessions, + ); + const result = JSON.parse(tool.resultText) as { sessions: readonly { session: string }[] }; + const returned = new Set(result.sessions.map(item => item.session)); + + assert.deepStrictEqual({ + includesActive: returned.has(session.sessionUri), + includesArchived: returned.has(archived.sessionUri), + }, { + includesActive: true, + includesArchived: true, + }); + }); + serverToolTest('server tool: list_sessions unread filter returns the invoking unread session', async function () { const session = await createSession('sessions-unread'); const { tool } = await driveServerTool( @@ -540,6 +614,19 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void assert.ok(result.sessions.some(item => item.session === session.sessionUri)); }); + serverToolTest('server tool: list_sessions createdAfter excludes sessions before the boundary', async function () { + const session = await createSession('sessions-created-after-exclude'); + const { tool } = await driveServerTool( + session, + 'turn-sessions-created-after-exclude', + 'Call list_sessions exactly once with createdAfter "2999-01-01T00:00:00Z", then reply exactly "filtered".', + SessionServerToolName.ListSessions, + ); + const result = JSON.parse(tool.resultText) as { sessions: readonly { session: string }[] }; + + assert.strictEqual(result.sessions.some(item => item.session === session.sessionUri), false); + }); + serverToolTest('server tool: list_sessions createdBefore excludes current sessions', async function () { const session = await createSession('sessions-created-before'); const { tool } = await driveServerTool( @@ -552,6 +639,19 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void assert.strictEqual(result.sessions.some(item => item.session === session.sessionUri), false); }); + serverToolTest('server tool: list_sessions createdBefore accepts sessions before a future boundary', async function () { + const session = await createSession('sessions-created-before-include'); + const { tool } = await driveServerTool( + session, + 'turn-sessions-created-before-include', + 'Call list_sessions exactly once with createdBefore "2999-01-01T00:00:00Z", then reply exactly "filtered".', + SessionServerToolName.ListSessions, + ); + const result = JSON.parse(tool.resultText) as { sessions: readonly { session: string }[] }; + + assert.ok(result.sessions.some(item => item.session === session.sessionUri)); + }); + serverToolTest('server tool: create_chat defaults to the invoking session and starts its local prompt', async function () { const session = await createSession('create-chat-default'); const before = new Set((await sessionState(session.sessionUri)).chats.map(chat => chat.resource)); @@ -609,6 +709,76 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void }); }); + serverToolTest('server tool: get_session_context accepts explicit summary detail', async function () { + const session = await createSession('context-explicit-summary', true); + await driveTurnToCompletion(context.client, session.sessionUri, 'turn-context-explicit-summary-seed', 'Reply exactly "SUMMARY_READY".', reserveClientSequenceBlock()); + const { tool } = await driveServerTool( + session, + 'turn-context-explicit-summary', + `Call get_session_context exactly once with session "${session.sessionUri}" and detail "summary", then reply exactly "read".`, + SessionServerToolName.GetSessionContext, + ); + const result = JSON.parse(tool.resultText) as { detail: string; transcript: readonly { user?: string; assistant?: string }[] }; + + assert.deepStrictEqual({ + detail: result.detail, + first: result.transcript[0], + }, { + detail: 'summary', + first: { + turn: 1, + state: 'complete', + user: 'Reply exactly "SUMMARY_READY".', + assistant: 'SUMMARY_READY', + }, + }); + }); + + serverToolTest('server tool: get_session_context accepts an open-session link', async function () { + const session = await createSession('context-link', true); + await driveTurnToCompletion(context.client, session.sessionUri, 'turn-context-link-seed', 'Reply exactly "LINK_READY".', reserveClientSequenceBlock()); + const link = buildOpenSessionLinkUri(URI.parse(session.sessionUri)); + const { tool } = await driveServerTool( + session, + 'turn-context-link', + `Call get_session_context exactly once with session "${link}", then reply exactly "read".`, + SessionServerToolName.GetSessionContext, + ); + const result = JSON.parse(tool.resultText) as { transcript: readonly { user?: string; assistant?: string }[] }; + + assert.deepStrictEqual(result.transcript[0], { + turn: 1, + state: 'complete', + user: 'Reply exactly "LINK_READY".', + assistant: 'LINK_READY', + }); + }); + + serverToolTest('server tool: get_session_context digest includes completed response text', async function () { + const session = await createSession('context-digest', true); + await driveTurnToCompletion(context.client, session.sessionUri, 'turn-context-digest-seed', 'Reply exactly "DIGEST_READY".', reserveClientSequenceBlock()); + const { tool } = await driveServerTool( + session, + 'turn-context-digest', + `Call get_session_context exactly once with session "${session.sessionUri}" and detail "digest", then reply exactly "read".`, + SessionServerToolName.GetSessionContext, + ); + const result = JSON.parse(tool.resultText) as { detail: string; transcript: readonly { user?: string; assistant?: string }[] }; + + assert.deepStrictEqual({ + detail: result.detail, + first: result.transcript[0], + }, { + detail: 'digest', + first: { + turn: 1, + state: 'complete', + user: 'Reply exactly "DIGEST_READY".', + assistant: 'DIGEST_READY', + }, + }); + }); + serverToolTest('server tool: get_session_context full includes prior server-tool input', async function () { const session = await createSession('context-full', true); await driveServerTool( diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts index fb883481c6d447..50b05e209afd93 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts @@ -15,6 +15,7 @@ import type { SubscribeResult } from '../../../../common/state/protocol/commands import { TerminalClaimKind, type TerminalClaim } from '../../../../common/state/protocol/state.js'; import { buildDefaultChatUri, + MessageAttachmentKind, MessageKind, PendingMessageKind, ROOT_STATE_URI, @@ -149,6 +150,22 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v assert.strictEqual((await sessionState(sessionUri)).status & SessionStatus.IsArchived, 0); }); + conformanceTest(context, 'session read and archived flags compose independently', async function () { + const { sessionUri } = await createSession('status-compose'); + + await dispatchAndWait(sessionUri, 1, { type: ActionType.SessionIsReadChanged, isRead: true }); + await dispatchAndWait(sessionUri, 2, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + const state = await sessionState(sessionUri); + + assert.deepStrictEqual({ + isRead: (state.status & SessionStatus.IsRead) !== 0, + isArchived: (state.status & SessionStatus.IsArchived) !== 0, + }, { + isRead: true, + isArchived: true, + }); + }); + conformanceTest(context, 'session config changes merge with existing values', async function () { const { sessionUri } = await createSession('config-merge'); const before = await sessionState(sessionUri); @@ -178,6 +195,22 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v }); }); + conformanceTest(context, 'empty session config replacement clears previous values', async function () { + const { sessionUri } = await createSession('config-clear'); + await dispatchAndWait(sessionUri, 1, { + type: ActionType.SessionConfigChanged, + config: { [SessionConfigKey.AutoApprove]: 'assisted' }, + }); + + await dispatchAndWait(sessionUri, 2, { + type: ActionType.SessionConfigChanged, + config: {}, + replace: true, + }); + + assert.deepStrictEqual((await sessionState(sessionUri)).config?.values, {}); + }); + conformanceTest(context, 'active client set adds a session participant', async function () { const { sessionUri, clientId } = await createSession('active-client-add'); @@ -193,6 +226,30 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v }]); }); + conformanceTest(context, 'active client tools retain their protocol schemas', async function () { + const { sessionUri, clientId } = await createSession('active-client-tools'); + const tools = [{ + name: 'coverage_echo', + description: 'Echoes a value', + inputSchema: { + type: 'object' as const, + properties: { value: { type: 'string' } }, + required: ['value'], + }, + }]; + + await dispatchAndWait(sessionUri, 1, { + type: ActionType.SessionActiveClientSet, + activeClient: { clientId, displayName: 'Tool Client', tools }, + }); + + assert.deepStrictEqual((await sessionState(sessionUri)).activeClients, [{ + clientId, + displayName: 'Tool Client', + tools, + }]); + }); + conformanceTest(context, 'active client set replaces an existing participant', async function () { const { sessionUri, clientId } = await createSession('active-client-update'); await dispatchAndWait(sessionUri, 1, { @@ -208,6 +265,40 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v assert.deepStrictEqual((await sessionState(sessionUri)).activeClients.map(client => client.displayName), ['After']); }); + conformanceTest(context, 'two active clients remain independently addressable', async function () { + const { sessionUri, clientId } = await createSession('active-client-multiple'); + + await dispatchAndWait(sessionUri, 1, { + type: ActionType.SessionActiveClientSet, + activeClient: { clientId, displayName: 'First', tools: [] }, + }); + await dispatchAndWait(sessionUri, 2, { + type: ActionType.SessionActiveClientSet, + activeClient: { clientId: 'second-client', displayName: 'Second', tools: [] }, + }); + + assert.deepStrictEqual((await sessionState(sessionUri)).activeClients.map(client => client.clientId).sort(), [ + clientId, + 'second-client', + ].sort()); + }); + + conformanceTest(context, 'removing one active client preserves its sibling', async function () { + const { sessionUri, clientId } = await createSession('active-client-remove-one'); + await dispatchAndWait(sessionUri, 1, { + type: ActionType.SessionActiveClientSet, + activeClient: { clientId, displayName: 'First', tools: [] }, + }); + await dispatchAndWait(sessionUri, 2, { + type: ActionType.SessionActiveClientSet, + activeClient: { clientId: 'second-client', displayName: 'Second', tools: [] }, + }); + + await dispatchAndWait(sessionUri, 3, { type: ActionType.SessionActiveClientRemoved, clientId }); + + assert.deepStrictEqual((await sessionState(sessionUri)).activeClients.map(client => client.clientId), ['second-client']); + }); + conformanceTest(context, 'active client removal removes the session participant', async function () { const { sessionUri, clientId } = await createSession('active-client-remove'); await dispatchAndWait(sessionUri, 1, { @@ -229,6 +320,35 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v assert.deepStrictEqual((await chatState(chatUri)).draft, draft); }); + conformanceTest(context, 'draft change preserves resource attachments', async function () { + const { chatUri, workspace } = await createSession('draft-attachment'); + const draft: Message = { + ...userMessage('review this'), + attachments: [{ + type: MessageAttachmentKind.Resource, + uri: URI.file(join(workspace, 'draft.ts')).toString(), + label: 'draft.ts', + displayKind: 'document', + }], + }; + + await dispatchAndWait(chatUri, 1, { type: ActionType.ChatDraftChanged, draft }); + + assert.deepStrictEqual((await chatState(chatUri)).draft, draft); + }); + + conformanceTest(context, 'draft change preserves the selected model', async function () { + const { chatUri } = await createSession('draft-model'); + const draft: Message = { + ...userMessage('model draft'), + model: { id: 'coverage-model' }, + }; + + await dispatchAndWait(chatUri, 1, { type: ActionType.ChatDraftChanged, draft }); + + assert.deepStrictEqual((await chatState(chatUri)).draft, draft); + }); + conformanceTest(context, 'draft change replaces the previous message', async function () { const { chatUri } = await createSession('draft-replace'); await dispatchAndWait(chatUri, 1, { type: ActionType.ChatDraftChanged, draft: userMessage('before') }); @@ -358,6 +478,36 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v }); }); + conformanceTest(context, 'successive terminal resizes retain the latest dimensions', async function () { + await withTerminal('terminal-resize-latest', async ({ terminalUri }) => { + await dispatchAndWait(terminalUri, 1, { type: ActionType.TerminalResized, cols: 100, rows: 35 }); + await dispatchAndWait(terminalUri, 2, { type: ActionType.TerminalResized, cols: 140, rows: 50 }); + + const state = await terminalState(terminalUri); + assert.deepStrictEqual({ cols: state.cols, rows: state.rows }, { cols: 140, rows: 50 }); + }); + }); + + conformanceTest(context, 'terminal claim transfer preserves dimensions and cwd', async function () { + await withTerminal('terminal-claim-metadata', async ({ sessionUri, terminalUri, workspace }) => { + await dispatchAndWait(terminalUri, 1, { + type: ActionType.TerminalClaimed, + claim: { kind: TerminalClaimKind.Session, session: sessionUri }, + }); + + const state = await terminalState(terminalUri); + assert.deepStrictEqual({ + cwd: state.cwd, + cols: state.cols, + rows: state.rows, + }, { + cwd: URI.file(workspace).fsPath, + cols: 90, + rows: 30, + }); + }); + }); + conformanceTest(context, 'terminal title change is broadcast', async function () { await withTerminal('terminal-title', async ({ terminalUri }) => { context.client.clearReceived(); @@ -383,6 +533,35 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v }); }); + conformanceTest(context, 'terminal claim can transfer back to the client', async function () { + await withTerminal('terminal-claim-return', async ({ sessionUri, terminalUri, clientId }) => { + await dispatchAndWait(terminalUri, 1, { + type: ActionType.TerminalClaimed, + claim: { kind: TerminalClaimKind.Session, session: sessionUri }, + }); + const clientClaim: TerminalClaim = { kind: TerminalClaimKind.Client, clientId }; + + await dispatchAndWait(terminalUri, 2, { type: ActionType.TerminalClaimed, claim: clientClaim }); + + assert.deepStrictEqual((await terminalState(terminalUri)).claim, clientClaim); + }); + }); + + conformanceTest(context, 'session terminal claims preserve turn and tool identifiers', async function () { + await withTerminal('terminal-session-claim', async ({ sessionUri, terminalUri }) => { + const claim: TerminalClaim = { + kind: TerminalClaimKind.Session, + session: sessionUri, + turnId: 'turn-claim', + toolCallId: 'tool-claim', + }; + + await dispatchAndWait(terminalUri, 1, { type: ActionType.TerminalClaimed, claim }); + + assert.deepStrictEqual((await terminalState(terminalUri)).claim, claim); + }); + }); + conformanceTest(context, 'terminal input reaches the shell and produces output', async function () { await withTerminal('terminal-input', async ({ terminalUri }) => { context.client.clearReceived(); @@ -405,6 +584,27 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v }); }); + conformanceTest(context, 'terminal input preserves Unicode output', async function () { + await withTerminal('terminal-unicode', async ({ terminalUri }) => { + context.client.clearReceived(); + context.client.dispatch({ + channel: terminalUri, + clientSeq: 1, + action: { type: ActionType.TerminalInput, data: 'node -e "console.log(\'SNOWMAN_\'+String.fromCodePoint(0x2603))"\r' }, + }); + let streamedOutput = ''; + await context.client.waitForNotification(n => { + if (!isActionNotification(n, 'terminal/data') || getActionEnvelope(n).channel !== terminalUri) { + return false; + } + streamedOutput += (getActionEnvelope(n).action as { data: string }).data; + return streamedOutput.includes('SNOWMAN_\u2603'); + }, 30_000); + + assert.match(terminalText(await terminalState(terminalUri)), /SNOWMAN_\u2603/); + }); + }); + conformanceTest(context, 'clearing a terminal drops the scrollback the client already saw', async function () { await withTerminal('terminal-clear', async ({ terminalUri }) => { context.client.clearReceived(); @@ -444,6 +644,25 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v }); }); + conformanceTest(context, 'clearing a terminal preserves dimensions and claim', async function () { + await withTerminal('terminal-clear-metadata', async ({ terminalUri, clientId }) => { + await dispatchAndWait(terminalUri, 1, { type: ActionType.TerminalResized, cols: 111, rows: 37 }); + + await dispatchAndWait(terminalUri, 2, { type: ActionType.TerminalCleared }); + + const state = await terminalState(terminalUri); + assert.deepStrictEqual({ + cols: state.cols, + rows: state.rows, + claim: state.claim, + }, { + cols: 111, + rows: 37, + claim: { kind: TerminalClaimKind.Client, clientId }, + }); + }); + }); + conformanceTest(context, 'a terminal whose shell exits reports its exit code', async function () { await withTerminal('terminal-exit', async ({ terminalUri }) => { context.client.clearReceived(); @@ -517,6 +736,84 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v }); }); + conformanceTest(context, 'root terminal metadata reflects title changes', async function () { + await withTerminal('terminal-root-title', async ({ terminalUri }) => { + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + context.client.clearReceived(); + context.client.dispatch({ + channel: terminalUri, + clientSeq: 1, + action: { type: ActionType.TerminalTitleChanged, title: 'Root Metadata Title' }, + }); + + const changed = await context.client.waitForNotification(n => { + if (!isActionNotification(n, 'root/terminalsChanged')) { + return false; + } + const terminals = (getActionEnvelope(n).action as { terminals?: readonly { resource: string; title: string }[] }).terminals; + return terminals?.some(terminal => terminal.resource === terminalUri && terminal.title === 'Root Metadata Title') ?? false; + }); + + assert.ok(isActionNotification(changed, 'root/terminalsChanged')); + }); + }); + + conformanceTest(context, 'root terminal metadata reflects claim transfers', async function () { + await withTerminal('terminal-root-claim', async ({ sessionUri, terminalUri }) => { + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + const claim: TerminalClaim = { kind: TerminalClaimKind.Session, session: sessionUri, turnId: 'turn-root-claim' }; + context.client.clearReceived(); + context.client.dispatch({ + channel: terminalUri, + clientSeq: 1, + action: { type: ActionType.TerminalClaimed, claim }, + }); + + const changed = await context.client.waitForNotification(n => { + if (!isActionNotification(n, 'root/terminalsChanged')) { + return false; + } + const terminals = (getActionEnvelope(n).action as { terminals?: readonly { resource: string; claim: TerminalClaim }[] }).terminals; + return terminals?.some(terminal => + terminal.resource === terminalUri + && terminal.claim.kind === TerminalClaimKind.Session + && terminal.claim.session === claim.session + && terminal.claim.turnId === claim.turnId, + ) ?? false; + }); + + assert.ok(isActionNotification(changed, 'root/terminalsChanged')); + }); + }); + + conformanceTest(context, 'an exited terminal remains discoverable with its exit code until disposal', async function () { + await withTerminal('terminal-root-exit', async ({ terminalUri }) => { + context.client.clearReceived(); + context.client.dispatch({ + channel: terminalUri, + clientSeq: 1, + action: { type: ActionType.TerminalInput, data: 'exit\r' }, + }); + const exited = await context.client.waitForNotification(n => + isActionNotification(n, 'terminal/exited') && getActionEnvelope(n).channel === terminalUri, + 30_000, + ); + const exitCode = (getActionEnvelope(exited).action as { exitCode?: number }).exitCode; + + const root = await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + const terminal = (root.snapshot!.state as RootState).terminals?.find(terminal => terminal.resource === terminalUri); + assert.deepStrictEqual({ + listed: terminal !== undefined, + reportedExitCode: typeof exitCode, + stateMatchesNotification: terminal?.exitCode === exitCode, + }, { + listed: true, + reportedExitCode: 'number', + stateMatchesNotification: true, + }); + }); + }); + conformanceTest(context, 'disposeTerminal removes the terminal from root state', async function () { const { terminalUri } = await createTerminal('terminal-dispose'); From 70698c24b33320f227ea18231a48b1fdc3190f8b Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 15 Aug 2026 23:44:40 -0700 Subject: [PATCH 3/4] Fix virtual list active descendant ordering (#330945) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/browser/ui/list/listWidget.ts | 14 ++++- .../test/browser/ui/list/listWidget.test.ts | 54 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/vs/base/browser/ui/list/listWidget.ts b/src/vs/base/browser/ui/list/listWidget.ts index 4b191b9dd833e0..45458741adbd96 100644 --- a/src/vs/base/browser/ui/list/listWidget.ts +++ b/src/vs/base/browser/ui/list/listWidget.ts @@ -1560,6 +1560,7 @@ export class List implements ISpliceable, IDisposable { this.onDidChangeFocus(this._onFocusChange, this, this.disposables); this.onDidChangeSelection(this._onSelectionChange, this, this.disposables); + this.view.onDidScroll(this.onDidChangeActiveDescendant, this, this.disposables); if (this.accessibilityProvider) { const ariaLabel = this.accessibilityProvider.getWidgetAriaLabel(); @@ -2059,13 +2060,22 @@ export class List implements ISpliceable, IDisposable { const focus = this.focus.get(); if (focus.length > 0) { + const index = focus[0]; let id: string | undefined; if (this.accessibilityProvider?.getActiveDescendantId) { - id = this.accessibilityProvider.getActiveDescendantId(this.view.element(focus[0])); + id = this.accessibilityProvider.getActiveDescendantId(this.view.element(index)); } - this.view.domNode.setAttribute('aria-activedescendant', id || this.view.getElementDomId(focus[0])); + if (!id && this.view.domElement(index)) { + id = this.view.getElementDomId(index); + } + + if (id) { + this.view.domNode.setAttribute('aria-activedescendant', id); + } else { + this.view.domNode.removeAttribute('aria-activedescendant'); + } } else { this.view.domNode.removeAttribute('aria-activedescendant'); } diff --git a/src/vs/base/test/browser/ui/list/listWidget.test.ts b/src/vs/base/test/browser/ui/list/listWidget.test.ts index 98dc9d1ef51467..455baa025dc354 100644 --- a/src/vs/base/test/browser/ui/list/listWidget.test.ts +++ b/src/vs/base/test/browser/ui/list/listWidget.test.ts @@ -93,4 +93,58 @@ suite('ListWidget', function () { await timeout(0); assert.strictEqual(listWidget.getFocus()[0], 0, 'page up to next page'); }); + + test('aria-activedescendant references a rendered element', function () { + const element = document.createElement('div'); + element.style.height = '20px'; + element.style.width = '200px'; + + const delegate: IListVirtualDelegate = { + getHeight() { return 20; }, + getTemplateId() { return 'template'; } + }; + + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate() { }, + renderElement() { }, + disposeTemplate() { } + }; + + const listWidget = store.add(new List('test', element, delegate, [renderer], { + accessibilityProvider: { + getAriaLabel: element => String(element), + getWidgetAriaLabel: () => 'Test list', + getActiveDescendantId: () => undefined + } + })); + listWidget.layout(20); + listWidget.splice(0, 0, range(100)); + + const listElement = element.querySelector('.monaco-list')!; + const focusedElementId = listWidget.getElementID(50); + + listWidget.setFocus([50]); + const beforeReveal = { + activeDescendant: listElement.getAttribute('aria-activedescendant'), + focusedElementRendered: element.querySelector(`#${focusedElementId}`) !== null + }; + + listWidget.reveal(50); + const afterReveal = { + activeDescendant: listElement.getAttribute('aria-activedescendant'), + focusedElementRendered: element.querySelector(`#${focusedElementId}`) !== null + }; + + assert.deepStrictEqual({ beforeReveal, afterReveal }, { + beforeReveal: { + activeDescendant: null, + focusedElementRendered: false + }, + afterReveal: { + activeDescendant: focusedElementId, + focusedElementRendered: true + } + }); + }); }); From 09b07fe4007df5c64c1be692efdbb27eb874e06a Mon Sep 17 00:00:00 2001 From: Martin Check <40643496+martincheck@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:59:52 -0700 Subject: [PATCH 4/4] chat: avoid splitting surrogate pairs in read_file (#331005) --- .../src/extension/tools/node/readFileTool.tsx | 7 ++++++- .../extension/tools/node/test/readFile.spec.tsx | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/tools/node/readFileTool.tsx b/extensions/copilot/src/extension/tools/node/readFileTool.tsx index 06fb90b91920ce..359d5995dcdec3 100644 --- a/extensions/copilot/src/extension/tools/node/readFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/readFileTool.tsx @@ -22,6 +22,7 @@ import { IWorkspaceService } from '../../../platform/workspace/common/workspaceS import { getCachedSha256Hash } from '../../../util/common/crypto'; import { clamp } from '../../../util/vs/base/common/numbers'; import { dirname, extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources'; +import { isHighSurrogate, isLowSurrogate } from '../../../util/vs/base/common/strings'; import { sendSkillContentReadTelemetry } from '../common/skillTelemetry'; import { URI } from '../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; @@ -432,7 +433,11 @@ class ReadFileResult extends PromptElement { let contents = rawContents.split('\n').map(line => { if (line.length > MAX_LINE_LENGTH) { hadLongLines = true; - return line.slice(0, MAX_LINE_LENGTH) + ' [truncated]'; + let end = MAX_LINE_LENGTH; + if (isHighSurrogate(line.charCodeAt(end - 1)) && isLowSurrogate(line.charCodeAt(end))) { + end--; + } + return line.slice(0, end) + ' [truncated]'; } return line; }).join('\n'); diff --git a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx index 7e8206e2e4740d..548aeaf443bd1a 100644 --- a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx +++ b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx @@ -42,13 +42,15 @@ suite('ReadFile', () => { const longLine = 'x'.repeat(2500); const longLinesContent = `normal line\n${longLine}\nanother normal line\n${longLine}`; const longLinesDoc = createTextDocumentData(URI.file('/workspace/longlines.ts'), longLinesContent, 'ts').document; + const surrogateBoundaryLine = 'x'.repeat(1999) + '\u{1F6E1}' + 'tail'; + const surrogateBoundaryDoc = createTextDocumentData(URI.file('/workspace/surrogate-boundary.ts'), surrogateBoundaryLine, 'ts').document; const services = createExtensionUnitTestingServices(); services.define(IWorkspaceService, new SyncDescriptor( TestWorkspaceService, [ [URI.file('/workspace')], - [testDoc, emptyDoc, whitespaceDoc, singleLineDoc, largeDoc, longLinesDoc], + [testDoc, emptyDoc, whitespaceDoc, singleLineDoc, largeDoc, longLinesDoc, surrogateBoundaryDoc], ] )); accessor = services.createTestingAccessor(); @@ -208,6 +210,18 @@ suite('ReadFile', () => { } }); + test('long line truncation does not split surrogate pairs', async () => { + const toolsService = accessor.get(IToolsService); + const input: IReadFileParamsV2 = { + filePath: '/workspace/surrogate-boundary.ts' + }; + const result = await toolsService.invokeTool(ToolName.ReadFile, { input, toolInvocationToken: null as never }, CancellationToken.None); + const resultString = await toolResultToString(accessor, result); + const truncatedLine = resultString.split('\n').find(line => line.endsWith(' [truncated]')); + + expect(truncatedLine).toBe('x'.repeat(1999) + ' [truncated]'); + }); + test('read file with offset beyond file line count should throw error', async () => { const toolsService = accessor.get(IToolsService);