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); 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 + } + }); + }); }); 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/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'); 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);