From 64b470f88a00634fca0f2676ff67885337529628 Mon Sep 17 00:00:00 2001 From: cwebster-99 Date: Fri, 17 Apr 2026 15:39:18 -0500 Subject: [PATCH 001/114] Change close button --- .../browser/media/variationA.css | 31 +++++++++++++++++++ .../browser/onboardingVariationA.ts | 29 +++++++---------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css b/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css index 8945687a86c..cacd6a0acc6 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css @@ -42,6 +42,7 @@ display: flex; flex-direction: column; overflow: hidden; + position: relative; transform: scale(0.96) translateY(8px); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); } @@ -61,6 +62,36 @@ padding: 20px 28px 0; } +.onboarding-a-close-btn { + position: absolute; + top: 16px; + right: 16px; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + padding: 0; + flex-shrink: 0; + transition: background 0.15s ease, color 0.15s ease; +} + +.onboarding-a-close-btn:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(255, 255, 255, 0.1)); + color: var(--vscode-editor-foreground); +} + +.onboarding-a-close-btn:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + .onboarding-a-progress { display: flex; align-items: center; diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index e1486cb5c58..d1551a512b6 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -105,7 +105,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi private contentEl: HTMLElement | undefined; private backButton: HTMLButtonElement | undefined; private nextButton: HTMLButtonElement | undefined; - private skipButton: HTMLButtonElement | undefined; + private closeButton: HTMLButtonElement | undefined; private footerLeft: HTMLElement | undefined; private _footerSignInBtn: HTMLButtonElement | undefined; @@ -176,6 +176,13 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi // Card this.card = append(this.overlay, $('.onboarding-a-card')); + // Close button (upper-right corner of card) + this.closeButton = append(this.card, $('button.onboarding-a-close-btn')); + this.closeButton.type = 'button'; + this.closeButton.setAttribute('aria-label', localize('onboarding.close', "Close")); + this.closeButton.appendChild(renderIcon(Codicon.close)); + this.footerFocusableElements.push(this.closeButton); + // Header with progress const header = append(this.card, $('.onboarding-a-header')); this.progressContainer = append(header, $('.onboarding-a-progress')); @@ -194,10 +201,6 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi const footer = append(this.card, $('.onboarding-a-footer')); this.footerLeft = append(footer, $('.onboarding-a-footer-left')); - this.skipButton = append(this.footerLeft, $('button.onboarding-a-btn.onboarding-a-btn-ghost')); - this.skipButton.textContent = localize('onboarding.skip', "Skip"); - this.skipButton.type = 'button'; - this.footerFocusableElements.push(this.skipButton); const footerRight = append(footer, $('.onboarding-a-footer-right')); @@ -212,7 +215,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this._updateButtonStates(); // Event handlers - this.disposables.add(addDisposableListener(this.skipButton, EventType.CLICK, () => { + this.disposables.add(addDisposableListener(this.closeButton, EventType.CLICK, () => { this._logAction('skip'); this._dismiss('skip'); })); @@ -412,15 +415,8 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this.nextButton.textContent = localize('onboarding.next', "Continue"); } } - if (this.skipButton && this.footerLeft) { - if (this.currentStepIndex === 0) { - // Sign-in step: ghost Skip button - this.skipButton.className = 'onboarding-a-btn onboarding-a-btn-ghost'; - } else { - this.skipButton.className = 'onboarding-a-btn onboarding-a-btn-ghost'; - } + if (this.footerLeft) { if (this._isLastStep()) { - this.skipButton.style.display = 'none'; // Show sign-in nudge in footer if (!this._footerSignInBtn && !this._userSignedIn) { this._footerSignInBtn = append(this.footerLeft, $('button.onboarding-a-signin-nudge-btn')); @@ -435,7 +431,6 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi })); } } else { - this.skipButton.style.display = ''; if (this._footerSignInBtn) { this._footerSignInBtn.remove(); this._footerSignInBtn = undefined; @@ -1154,7 +1149,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi private _focusCurrentStepElement(): void { const stepFocusable = this.stepFocusableElements.find(element => this._isTabbable(element)); - (stepFocusable ?? this.nextButton ?? this.skipButton)?.focus(); + (stepFocusable ?? this.nextButton ?? this.closeButton)?.focus(); } private _registerStepFocusable(element: T): T { @@ -1210,7 +1205,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this.contentEl = undefined; this.backButton = undefined; this.nextButton = undefined; - this.skipButton = undefined; + this.closeButton = undefined; this.footerLeft = undefined; this._footerSignInBtn = undefined; this.footerFocusableElements.length = 0; From cd2e1d8ed0550accc23671bfb3575acb345feced Mon Sep 17 00:00:00 2001 From: cwebster-99 Date: Fri, 17 Apr 2026 16:08:25 -0500 Subject: [PATCH 002/114] Refactor onboarding close button handling to ensure it is focusable Co-authored-by: Copilot --- .../contrib/welcomeOnboarding/browser/onboardingVariationA.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index d1551a512b6..7bea08f1ad0 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -181,7 +181,6 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this.closeButton.type = 'button'; this.closeButton.setAttribute('aria-label', localize('onboarding.close', "Close")); this.closeButton.appendChild(renderIcon(Codicon.close)); - this.footerFocusableElements.push(this.closeButton); // Header with progress const header = append(this.card, $('.onboarding-a-header')); @@ -1144,7 +1143,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi } private _getFocusableElements(): HTMLElement[] { - return [...this.stepFocusableElements, ...this.footerFocusableElements].filter(element => this._isTabbable(element)); + return [...(this.closeButton ? [this.closeButton] : []), ...this.stepFocusableElements, ...this.footerFocusableElements].filter(element => this._isTabbable(element)); } private _focusCurrentStepElement(): void { From 886c556841c7d1d31d9333b44e2cd332c380c7e3 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 17 Apr 2026 18:15:48 -0700 Subject: [PATCH 003/114] agentHost: adopt eager activeClient announcement --- .../browser/remoteAgentHostProtocolClient.ts | 1 + .../platform/agentHost/common/agentService.ts | 10 ++- .../common/state/protocol/.ahp-version | 2 +- .../common/state/protocol/commands.ts | 11 ++- .../agentHost/node/copilot/copilotAgent.ts | 7 ++ .../agentHost/node/protocolServerHandler.ts | 14 ++++ .../agentHost/test/node/copilotAgent.test.ts | 73 ++++++++++++++++++- .../test/node/protocolServerHandler.test.ts | 73 +++++++++++++++++++ .../agentHost/agentHostSessionHandler.ts | 12 ++- .../agentHostChatContribution.test.ts | 35 +++++++-- 10 files changed, 222 insertions(+), 16 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 601c78308de..70bdbae725d 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -188,6 +188,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC model: config?.model, workingDirectory: config?.workingDirectory ? fromAgentHostUri(config.workingDirectory).toString() : undefined, config: config?.config, + activeClient: config?.activeClient, }); return session; } diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 7116ca6c0b4..0d11bb4aaf1 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -12,7 +12,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from './state/protocol/commands.js'; -import { IProtectedResourceMetadata, type IConfigSchema, type IFileEdit, type IModelSelection, type IToolDefinition } from './state/protocol/state.js'; +import { IProtectedResourceMetadata, type IConfigSchema, type IFileEdit, type IModelSelection, type ISessionActiveClient, type IToolDefinition } from './state/protocol/state.js'; import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from './state/sessionActions.js'; import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type ICustomizationRef, type IPendingMessage, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type IToolCallResult, type IToolResultContent, type PolicyState, type StringOrMarkdown } from './state/sessionState.js'; @@ -125,6 +125,14 @@ export interface IAgentCreateSessionConfig { readonly session?: URI; readonly workingDirectory?: URI; readonly config?: Record; + /** + * Eagerly claim the active client role for the new session. When provided, + * the server initializes the session with this client as the active + * client, equivalent to dispatching a `session/activeClientChanged` + * action immediately after creation. The `clientId` MUST match the + * connection's own `clientId`. + */ + readonly activeClient?: ISessionActiveClient; /** Fork from an existing session at a specific turn. */ readonly fork?: { readonly session: URI; diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 79967dc6f68..f9c684bfe33 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -ab467b2 +0947b17 diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index f868bd271ae..a68103156dd 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { URI, ISnapshot, ISessionConfigSchema, ISessionSummary, IModelSelection, ITurn, ITerminalClaim } from './state.js'; +import type { URI, ISnapshot, ISessionConfigSchema, ISessionSummary, IModelSelection, ITurn, ITerminalClaim, ISessionActiveClient } from './state.js'; import type { IActionEnvelope, IStateAction } from './actions.js'; export type { IConfigPropertySchema, IConfigSchema, ISessionConfigPropertySchema, ISessionConfigSchema } from './state.js'; @@ -198,6 +198,15 @@ export interface ICreateSessionParams { * Keys and values correspond to the schema returned by the server. */ config?: Record; + /** + * Eagerly claim the active client role for the new session. + * + * When provided, the server initializes the session with this client as the + * active client, equivalent to dispatching a `session/activeClientChanged` + * action immediately after creation. The `clientId` MUST match the + * `clientId` the creating client supplied in `initialize`. + */ + activeClient?: ISessionActiveClient; } // ─── disposeSession ────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 05de458b78d..d87d8265722 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -449,6 +449,13 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionId = config?.session ? AgentSession.id(config.session) : generateUuid(); const sessionUri = AgentSession.uri(this.id, sessionId); + if (config?.activeClient) { + const ac = this._getOrCreateActiveClient(sessionUri); + ac.updateTools(config.activeClient.clientId, config.activeClient.tools); + if (config.activeClient.customizations?.length) { + await this._plugins.sync(config.activeClient.clientId, config.activeClient.customizations); + } + } const activeClient = this._activeClients.get(sessionUri); const snapshot = activeClient ? await activeClient.snapshot() : undefined; const workingDirectory = await this._resolveSessionWorkingDirectory(config, sessionId); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 6bc28f66b6b..fdd01b739a3 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -12,6 +12,7 @@ import { ILogService } from '../../log/common/log.js'; import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; import { AgentSession, type IAgentService } from '../common/agentService.js'; import type { ICommandMap } from '../common/state/protocol/messages.js'; +import { ActionType } from '../common/state/protocol/actions.js'; import { IActionEnvelope, INotification, isSessionAction, isTerminalAction, type ISessionAction } from '../common/state/sessionActions.js'; import { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { @@ -368,6 +369,19 @@ export class ProtocolServerHandler extends Disposable { if (createdSession.toString() !== URI.parse(params.session).toString()) { this._logService.warn(`[ProtocolServer] createSession: provider returned URI ${createdSession.toString()} but client requested ${params.session}`); } + // If the client eagerly claimed the active client role, dispatch + // `session/activeClientChanged` now so the claim is atomic with + // creation. + if (params.activeClient) { + if (params.activeClient.clientId !== _client.clientId) { + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `createSession.activeClient.clientId must match the connection's clientId`); + } + this._agentService.dispatchAction({ + type: ActionType.SessionActiveClientChanged, + session: createdSession.toString(), + activeClient: params.activeClient, + }, _client.clientId, 0); + } return null; }, disposeSession: async (_client, params) => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 40af0961605..557dfd54761 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -127,14 +127,14 @@ class TestableCopilotAgent extends CopilotAgent { } } -function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ICopilotClient }): CopilotAgent { +function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ICopilotClient; pluginManager?: IAgentPluginManager }): CopilotAgent { const services = new ServiceCollection(); const logService = new NullLogService(); const fileService = disposables.add(new FileService(logService)); services.set(ILogService, logService); services.set(IFileService, fileService); services.set(ISessionDataService, options?.sessionDataService ?? createNullSessionDataService()); - services.set(IAgentPluginManager, new TestAgentPluginManager()); + services.set(IAgentPluginManager, options?.pluginManager ?? new TestAgentPluginManager()); services.set(IAgentHostGitService, new TestAgentHostGitService()); services.set(IAgentHostTerminalManager, new TestAgentHostTerminalManager()); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); @@ -272,4 +272,73 @@ suite('CopilotAgent', () => { await disposeAgent(agent); } }); + + suite('createSession activeClient eager-claim', () => { + + class SpyingPluginManager extends TestAgentPluginManager { + public readonly calls: { clientId: string; customizations: ICustomizationRef[] }[] = []; + + override async syncCustomizations(clientId: string, customizations: ICustomizationRef[], _progress?: (status: ISessionCustomization[]) => void): Promise { + this.calls.push({ clientId, customizations: [...customizations] }); + return []; + } + } + + test('createSession seeds activeClient tools and syncs customizations', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + const pluginManager = new SpyingPluginManager(); + // Fail fast inside the SDK factory so we don't need to wire up a + // real raw session. The seeding of activeClient and the plugin + // sync both happen before `client.createSession` is invoked. + client.createSession = async () => { throw new Error('sentinel'); }; + + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, pluginManager }); + try { + await agent.authenticate('https://api.github.com', 'token'); + + const customizations: ICustomizationRef[] = [{ uri: 'file:///plugin-a', displayName: 'Plugin A' }]; + await assert.rejects( + agent.createSession({ + session: AgentSession.uri('copilot', 'test-session'), + workingDirectory: URI.file('/workspace'), + activeClient: { + clientId: 'client-1', + tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], + customizations, + }, + }), + (err: Error) => /sentinel/.test(err.message), + ); + + assert.deepStrictEqual(pluginManager.calls, [{ clientId: 'client-1', customizations }]); + } finally { + await disposeAgent(agent); + } + }); + + test('createSession without activeClient does not sync customizations', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + const pluginManager = new SpyingPluginManager(); + client.createSession = async () => { throw new Error('sentinel'); }; + + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, pluginManager }); + try { + await agent.authenticate('https://api.github.com', 'token'); + + await assert.rejects( + agent.createSession({ + session: AgentSession.uri('copilot', 'test-session-2'), + workingDirectory: URI.file('/workspace'), + }), + (err: Error) => /sentinel/.test(err.message), + ); + + assert.deepStrictEqual(pluginManager.calls, []); + } finally { + await disposeAgent(agent); + } + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 0ceb1291a25..fff677d8519 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -577,4 +577,77 @@ suite('ProtocolServerHandler', () => { transport2.simulateClose(); assert.deepStrictEqual(counts, [1, 1, 0]); }); + + // ---- createSession activeClient ------------------------------------- + + suite('createSession activeClient', () => { + + test('forwards activeClient as SessionActiveClientChanged', async () => { + const newSession = URI.parse('copilot:///eager-session').toString(); + // Pre-create the session so the client can subscribe at handshake + // time. MockAgentService.createSession below is idempotent against + // state-manager duplicates. + stateManager.createSession(makeSessionSummary(newSession)); + + const transport = connectClient('client-1', [newSession]); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'createSession', { + session: newSession, + provider: 'copilot', + activeClient: { + clientId: 'client-1', + tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], + customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }], + }, + })); + await responsePromise; + + const activeClientMsgs = findNotifications(transport.sent, 'action').filter(m => { + const envelope = m.params as unknown as { action: { type: string } }; + return envelope.action.type === ActionType.SessionActiveClientChanged; + }); + assert.strictEqual(activeClientMsgs.length, 1, 'should emit exactly one SessionActiveClientChanged'); + const envelope = activeClientMsgs[0].params as unknown as { action: { session: string; activeClient: { clientId: string; tools: { name: string }[]; customizations?: { uri: string }[] } } }; + assert.deepStrictEqual({ + session: envelope.action.session, + clientId: envelope.action.activeClient.clientId, + toolName: envelope.action.activeClient.tools[0].name, + customizationUri: envelope.action.activeClient.customizations?.[0].uri, + }, { + session: newSession, + clientId: 'client-1', + toolName: 't1', + customizationUri: 'file:///plugin-a', + }); + }); + + test('rejects createSession when activeClient.clientId mismatches', async () => { + const newSession = URI.parse('copilot:///mismatch-session').toString(); + stateManager.createSession(makeSessionSummary(newSession)); + + const transport = connectClient('client-1', [newSession]); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'createSession', { + session: newSession, + provider: 'copilot', + activeClient: { + clientId: 'other-client', + tools: [], + }, + })); + const resp = await responsePromise as { result?: unknown; error?: { code: number; message: string } }; + + assert.ok(resp.error, 'response should be an error'); + assert.strictEqual(resp.result, undefined); + const activeClientMsgs = findNotifications(transport.sent, 'action').filter(m => { + const envelope = m.params as unknown as { action: { type: string } }; + return envelope.action.type === ActionType.SessionActiveClientChanged; + }); + assert.strictEqual(activeClientMsgs.length, 0, 'no activeClient notification should have been emitted'); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 73fa5961b06..7773881908a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2250,6 +2250,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } + const activeClient = { + clientId: this._config.connection.clientId, + tools: this._clientToolsObs.get().map(toolDataToDefinition), + customizations: this._config.customizations?.get() ?? [], + }; + let session: URI; try { session = await this._config.connection.createSession({ @@ -2258,6 +2264,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC workingDirectory, fork, config, + activeClient, }); } catch (err) { // If authentication is required (e.g. token expired), try interactive auth and retry once @@ -2271,6 +2278,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC workingDirectory, fork, config, + activeClient, }); } else { throw new Error(localize('agentHost.authRequired', "Authentication is required to start a session. Please sign in and try again.")); @@ -2291,10 +2299,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }); } - // Claim the active client role with current customizations - const customizations = this._config.customizations?.get() ?? []; - this._dispatchActiveClient(session, customizations); - // Start syncing the chat model's pending requests to the protocol this._ensurePendingMessageSubscription(sessionResource, session); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 18bb78406a2..2180ae382c7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -82,6 +82,26 @@ class MockAgentHostService extends mock() { const id = `sdk-session-${this._nextId++}`; const session = AgentSession.uri('copilot', id); this._sessions.set(id, { session, startTime: Date.now(), modifiedTime: Date.now() }); + // Simulate the server's eager active-client claim: if the caller + // provided activeClient, seed the session state so subscribers see it. + if (config?.activeClient) { + const summary: ISessionSummary = { + resource: session.toString(), + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + const initial: ISessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }; + const action: ISessionAction = { + type: 'session/activeClientChanged' as const, + session: session.toString(), + activeClient: config.activeClient, + } as ISessionAction; + const withClient = sessionReducer(initial, action as Parameters[1], () => { }); + this.sessionStates.set(session.toString(), withClient); + } return session; } @@ -2491,13 +2511,14 @@ suite('AgentHostChatContribution', () => { fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); await turnPromise; - const activeClientAction = agentHostService.dispatchedActions.find( - d => d.action.type === 'session/activeClientChanged' - ); - assert.ok(activeClientAction, 'should dispatch activeClientChanged'); - const ac = activeClientAction!.action as { activeClient: { customizations?: ICustomizationRef[] } }; - assert.strictEqual(ac.activeClient.customizations?.length, 1); - assert.strictEqual(ac.activeClient.customizations?.[0].uri, 'file:///plugin-a'); + // The active-client claim is now threaded through createSession + // rather than dispatched separately, so assert on createSessionCalls. + const createCall = agentHostService.createSessionCalls.at(-1); + assert.ok(createCall?.activeClient, 'createSession should carry activeClient'); + assert.strictEqual(createCall!.activeClient!.clientId, agentHostService.clientId); + assert.ok(Array.isArray(createCall!.activeClient!.tools), 'activeClient.tools should be a defined array'); + assert.strictEqual(createCall!.activeClient!.customizations?.length, 1); + assert.strictEqual(createCall!.activeClient!.customizations?.[0].uri, 'file:///plugin-a'); }); test('re-dispatches activeClientChanged when customizations observable changes', async () => { From d4b070bf4bdc26e9c229b0506de81613c7af2ffa Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Sat, 18 Apr 2026 08:52:54 -0700 Subject: [PATCH 004/114] pr comments --- .../platform/agentHost/node/agentService.ts | 2 + .../agentHost/node/copilot/copilotAgent.ts | 7 +++- .../agentHost/node/protocolServerHandler.ts | 21 ++++------- .../agentHost/test/node/agentService.test.ts | 30 +++++++++++++++ .../test/node/protocolServerHandler.test.ts | 37 ++++++------------- .../agentHostChatContribution.test.ts | 12 +++--- 6 files changed, 62 insertions(+), 47 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index fc09b435bdd..82f826baf36 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -265,6 +265,7 @@ export class AgentService extends Disposable implements IAgentService { const state = this._stateManager.createSession(summary); state.config = sessionConfig; state.turns = sourceTurns; + state.activeClient = config.activeClient; } else { // Create empty state for new sessions const summary: ISessionSummary = { @@ -280,6 +281,7 @@ export class AgentService extends Disposable implements IAgentService { }; const state = this._stateManager.createSession(summary); state.config = sessionConfig; + state.activeClient = config?.activeClient; } this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() }); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index d87d8265722..35fcb7906a5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -449,10 +449,12 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionId = config?.session ? AgentSession.id(config.session) : generateUuid(); const sessionUri = AgentSession.uri(this.id, sessionId); + let seededActiveClient = false; if (config?.activeClient) { const ac = this._getOrCreateActiveClient(sessionUri); + seededActiveClient = true; ac.updateTools(config.activeClient.clientId, config.activeClient.tools); - if (config.activeClient.customizations?.length) { + if (config.activeClient.customizations !== undefined) { await this._plugins.sync(config.activeClient.clientId, config.activeClient.customizations); } } @@ -479,6 +481,9 @@ export class CopilotAgent extends Disposable implements IAgent { agentSession = this._createAgentSession(factory, sessionId, shellManager, snapshot); await agentSession.initializeSession(); } catch (error) { + if (seededActiveClient) { + this._activeClients.delete(sessionUri); + } await this._removeCreatedWorktree(sessionId); throw error; } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index fdd01b739a3..8c3d7563ca7 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -12,7 +12,6 @@ import { ILogService } from '../../log/common/log.js'; import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; import { AgentSession, type IAgentService } from '../common/agentService.js'; import type { ICommandMap } from '../common/state/protocol/messages.js'; -import { ActionType } from '../common/state/protocol/actions.js'; import { IActionEnvelope, INotification, isSessionAction, isTerminalAction, type ISessionAction } from '../common/state/sessionActions.js'; import { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { @@ -24,6 +23,7 @@ import { isJsonRpcNotification, isJsonRpcRequest, JSON_RPC_INTERNAL_ERROR, + JsonRpcErrorCodes, ProtocolError, type IAhpServerNotification, type IInitializeParams, @@ -350,6 +350,11 @@ export class ProtocolServerHandler extends Disposable { } fork = { session: URI.parse(params.fork.session), turnIndex, turnId: params.fork.turnId }; } + // If the client eagerly claimed the active client role, validate + // the clientId matches the connection before forwarding. + if (params.activeClient && params.activeClient.clientId !== _client.clientId) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `createSession.activeClient.clientId must match the connection's clientId`); + } try { createdSession = await this._agentService.createSession({ provider: params.provider, @@ -358,6 +363,7 @@ export class ProtocolServerHandler extends Disposable { session: URI.parse(params.session), fork, config: params.config, + activeClient: params.activeClient, }); } catch (err) { if (err instanceof ProtocolError) { @@ -369,19 +375,6 @@ export class ProtocolServerHandler extends Disposable { if (createdSession.toString() !== URI.parse(params.session).toString()) { this._logService.warn(`[ProtocolServer] createSession: provider returned URI ${createdSession.toString()} but client requested ${params.session}`); } - // If the client eagerly claimed the active client role, dispatch - // `session/activeClientChanged` now so the claim is atomic with - // creation. - if (params.activeClient) { - if (params.activeClient.clientId !== _client.clientId) { - throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `createSession.activeClient.clientId must match the connection's clientId`); - } - this._agentService.dispatchAction({ - type: ActionType.SessionActiveClientChanged, - session: createdSession.toString(), - activeClient: params.activeClient, - }, _client.clientId, 0); - } return null; }, disposeSession: async (_client, params) => { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 50ab00e0326..564b340f418 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -247,6 +247,36 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.config?.values, config); }); + + test('seeds activeClient into the initial session state when provided', async () => { + service.registerProvider(copilotAgent); + + const envelopes: IActionEnvelope[] = []; + disposables.add(service.onDidAction(env => envelopes.push(env))); + + const activeClient = { + clientId: 'client-eager', + tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], + customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }], + }; + const session = await service.createSession({ provider: 'copilot', activeClient }); + + assert.deepStrictEqual({ + activeClient: service.stateManager.getSessionState(session.toString())?.activeClient, + dispatchedActiveClientChanged: envelopes.some(e => e.action.type === ActionType.SessionActiveClientChanged), + }, { + activeClient, + dispatchedActiveClientChanged: false, + }); + }); + + test('omits activeClient from the initial session state when not provided', async () => { + service.registerProvider(copilotAgent); + + const session = await service.createSession({ provider: 'copilot' }); + + assert.strictEqual(service.stateManager.getSessionState(session.toString())?.activeClient, undefined); + }); }); // ---- authenticate --------------------------------------------------- diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index fff677d8519..29e1162661e 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -72,6 +72,7 @@ class MockAgentService implements IAgentService { readonly browsedUris: URI[] = []; readonly browseErrors = new Map(); readonly listedSessions: IAgentSessionMetadata[] = []; + readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; private readonly _onDidAction = new Emitter(); readonly onDidAction = this._onDidAction.event; @@ -91,6 +92,7 @@ class MockAgentService implements IAgentService { this._stateManager.dispatchClientAction(action, origin); } async createSession(config?: IAgentCreateSessionConfig): Promise { + this.createSessionConfigs.push(config); const session = config?.session ?? URI.parse('copilot:///new-session'); this._stateManager.createSession({ resource: session.toString(), @@ -582,14 +584,10 @@ suite('ProtocolServerHandler', () => { suite('createSession activeClient', () => { - test('forwards activeClient as SessionActiveClientChanged', async () => { + test('forwards activeClient to the agent service', async () => { const newSession = URI.parse('copilot:///eager-session').toString(); - // Pre-create the session so the client can subscribe at handshake - // time. MockAgentService.createSession below is idempotent against - // state-manager duplicates. - stateManager.createSession(makeSessionSummary(newSession)); - const transport = connectClient('client-1', [newSession]); + const transport = connectClient('client-1'); transport.sent.length = 0; const responsePromise = waitForResponse(transport, 2); @@ -602,21 +600,15 @@ suite('ProtocolServerHandler', () => { customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }], }, })); - await responsePromise; + const resp = await responsePromise as { result?: unknown; error?: unknown }; - const activeClientMsgs = findNotifications(transport.sent, 'action').filter(m => { - const envelope = m.params as unknown as { action: { type: string } }; - return envelope.action.type === ActionType.SessionActiveClientChanged; - }); - assert.strictEqual(activeClientMsgs.length, 1, 'should emit exactly one SessionActiveClientChanged'); - const envelope = activeClientMsgs[0].params as unknown as { action: { session: string; activeClient: { clientId: string; tools: { name: string }[]; customizations?: { uri: string }[] } } }; + assert.strictEqual(resp.error, undefined, 'createSession should succeed'); + const config = agentService.createSessionConfigs.at(-1); assert.deepStrictEqual({ - session: envelope.action.session, - clientId: envelope.action.activeClient.clientId, - toolName: envelope.action.activeClient.tools[0].name, - customizationUri: envelope.action.activeClient.customizations?.[0].uri, + clientId: config?.activeClient?.clientId, + toolName: config?.activeClient?.tools[0]?.name, + customizationUri: config?.activeClient?.customizations?.[0].uri, }, { - session: newSession, clientId: 'client-1', toolName: 't1', customizationUri: 'file:///plugin-a', @@ -625,9 +617,8 @@ suite('ProtocolServerHandler', () => { test('rejects createSession when activeClient.clientId mismatches', async () => { const newSession = URI.parse('copilot:///mismatch-session').toString(); - stateManager.createSession(makeSessionSummary(newSession)); - const transport = connectClient('client-1', [newSession]); + const transport = connectClient('client-1'); transport.sent.length = 0; const responsePromise = waitForResponse(transport, 2); @@ -643,11 +634,7 @@ suite('ProtocolServerHandler', () => { assert.ok(resp.error, 'response should be an error'); assert.strictEqual(resp.result, undefined); - const activeClientMsgs = findNotifications(transport.sent, 'action').filter(m => { - const envelope = m.params as unknown as { action: { type: string } }; - return envelope.action.type === ActionType.SessionActiveClientChanged; - }); - assert.strictEqual(activeClientMsgs.length, 0, 'no activeClient notification should have been emitted'); + assert.strictEqual(agentService.createSessionConfigs.length, 0, 'agent service should not have been called'); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 2180ae382c7..b0daf3a4ae5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -93,14 +93,12 @@ class MockAgentHostService extends mock() { createdAt: Date.now(), modifiedAt: Date.now(), }; - const initial: ISessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }; - const action: ISessionAction = { - type: 'session/activeClientChanged' as const, - session: session.toString(), + const state: ISessionState = { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, activeClient: config.activeClient, - } as ISessionAction; - const withClient = sessionReducer(initial, action as Parameters[1], () => { }); - this.sessionStates.set(session.toString(), withClient); + }; + this.sessionStates.set(session.toString(), state); } return session; } From a0abf264b86653d23be0642857d4c94c1084cb43 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Sat, 18 Apr 2026 09:13:20 -0700 Subject: [PATCH 005/114] fix build and duplicate logging issue --- .../agentHost/test/node/agentService.test.ts | 4 +-- .../agentHost/loggingAgentConnection.ts | 34 +++++++++++-------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 564b340f418..10aa0e2afb9 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -19,7 +19,7 @@ import { AgentSession } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.js'; +import { ISessionActiveClient, ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { MockAgent } from './mockAgent.js'; @@ -254,7 +254,7 @@ suite('AgentService (node dispatcher)', () => { const envelopes: IActionEnvelope[] = []; disposables.add(service.onDidAction(env => envelopes.push(env))); - const activeClient = { + const activeClient: ISessionActiveClient = { clientId: 'client-eager', tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }], diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts index 8879964026e..f5533e8d923 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { Disposable, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../../../../base/common/uri.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -98,6 +98,12 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti /** Ref-count per channel ID so the output channel survives reconnections. */ private static readonly _channelRefCounts = new Map(); private static readonly _currentRootStateLogKeys = new Set(); + /** + * Shared event-log subscription per channel ID. Multiple wrappers may + * exist for the same underlying connection (e.g. one for chat, one for + * terminal); we only want each event to appear once in the channel. + */ + private static readonly _sharedEventLog = new Map(); private _outputChannel: IOutputChannel | undefined; private readonly _enabled: boolean; @@ -130,6 +136,10 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti log: false, languageId: 'log', }); + const eventLogStore = new DisposableStore(); + eventLogStore.add(_inner.onDidAction(e => this._log('**', 'onDidAction', e))); + eventLogStore.add(_inner.onDidNotification(e => this._log('**', 'onDidNotification', e))); + LoggingAgentConnection._sharedEventLog.set(this.channelId, eventLogStore); } LoggingAgentConnection._channelRefCounts.set(this.channelId, refs + 1); logCurrentRootState = !LoggingAgentConnection._currentRootStateLogKeys.has(currentRootStateLogKey); @@ -141,6 +151,8 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti if (current <= 0) { LoggingAgentConnection._channelRefCounts.delete(this.channelId); LoggingAgentConnection._currentRootStateLogKeys.delete(currentRootStateLogKey); + LoggingAgentConnection._sharedEventLog.get(this.channelId)?.dispose(); + LoggingAgentConnection._sharedEventLog.delete(this.channelId); registry.removeChannel(this.channelId); } else { LoggingAgentConnection._channelRefCounts.set(this.channelId, current); @@ -148,20 +160,12 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti })); } - // Wrap events with logging - const onDidActionEmitter = this._register(new Emitter()); - this._register(_inner.onDidAction(e => { - this._log('**', 'onDidAction', e); - onDidActionEmitter.fire(e); - })); - this.onDidAction = onDidActionEmitter.event; - - const onDidNotificationEmitter = this._register(new Emitter()); - this._register(_inner.onDidNotification(e => { - this._log('**', 'onDidNotification', e); - onDidNotificationEmitter.fire(e); - })); - this.onDidNotification = onDidNotificationEmitter.event; + // Expose the inner events directly. Logging happens once per channel + // via the shared subscription registered above; wrappers must not + // add their own logging listener or events would be logged N times + // (once per wrapper for the same channel). + this.onDidAction = _inner.onDidAction; + this.onDidNotification = _inner.onDidNotification; this._rootState = this._register(new LoggingAgentSubscription('rootState', _inner.rootState, logCurrentRootState, (arrow, method, data) => this._log(arrow, method, data))); } From efd03c35e8847647556879655fed7632e3210000 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 19 Apr 2026 13:18:34 -0700 Subject: [PATCH 006/114] Surface worktree-created announcement in agent host Copilot CLI sessions (#311254) * Surface worktree-created announcement in agent host Copilot CLI sessions Mirrors the extension-host Copilot CLI behaviour. When a session is created with worktree isolation, a localized 'Created isolated worktree for branch X' markdown line is shown at the top of the first response. Live path: synthetic IAgentDeltaEvent fired before delegating to the SDK, so the announcement renders in real time as part of the first streaming reply. Restore path: branch name is persisted as session metadata; when the session is reopened, getSessionMessages prepends the announcement to the first top-level assistant message's content. Both paths are covered by an end-to-end test that drives a real worktree resolve through the in-memory SessionDatabase and verifies the synthetic delta is fired and the persisted announcement is prepended on restore. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Copilot review: DB-backed announcement flag + restart edge case test - Make buildWorktreeAnnouncementText and prependAnnouncementToFirstAssistantMessage non-exported (used only within the module). - Replace the in-memory _pendingFirstTurnAnnouncements map with a DB-backed copilot.worktree.announcementEmitted flag. The live path now reads both the branch name and the emitted flag from session metadata on every sendMessage, fires the synthetic delta exactly once, and persists the flag. This works across agent process restarts and removes the cleanup-on-failure leak the in-memory map had. - Add a regression test that creates a worktree session in one agent (only persisting metadata), disposes it without sending, then constructs a second agent against the same DB and verifies the announcement still fires on the first sendMessage and not on subsequent sends. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert worktree announcement to in-memory pending map The DB-backed 'announcementEmitted' flag was overkill. The only edge case it covered was the agent process restarting between worktree creation and the very first user losing the announcement inprompt that case is acceptable. Once any assistant message exists in the session, the restore path (via getSessionMessages prepending to the first assistant message) keeps the announcement durable across reopens, which is what we actually care about. Reverts to a simple per-process Map populated when the worktree is created and drained one-shot in sendMessage. Also drops the corresponding restart-edge-case test. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotAgent.ts | 124 ++++++++++- .../agentHost/test/node/copilotAgent.test.ts | 196 +++++++++++++++++- 2 files changed, 310 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 05de458b78d..e0d4cb214be 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -8,6 +8,7 @@ import { rgPath } from '@vscode/ripgrep'; import * as fs from 'fs/promises'; import { Limiter, SequencerByKey } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; +import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { FileAccess } from '../../../../base/common/network.js'; @@ -22,7 +23,7 @@ import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentHostSessionConfigBranchNameHintKey, AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { AgentHostSessionConfigBranchNameHintKey, AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { IProtectedResourceMetadata, type IConfigSchema, type IModelSelection, type IToolDefinition } from '../../common/state/protocol/state.js'; @@ -81,6 +82,49 @@ export function getCopilotWorktreeBranchName(sessionId: string, branchNameHint: return `agents/${branchNameHint ? `${branchNameHint}-${sessionId.substring(0, 8)}` : sessionId}`; } +/** + * Builds the localized "Created isolated worktree for branch X" markdown + * shown at the top of the first response in worktree-isolated sessions. + * The branch name is wrapped as inline code so the localized template + * doesn't have to embed markdown punctuation. The trailing blank line + * keeps the announcement visually separated when it gets merged into the + * same markdown part as the model's reply. + */ +function buildWorktreeAnnouncementText(branchName: string): string { + return localize( + 'copilotAgent.worktreeCreated', + "Created isolated worktree for branch {0}", + appendEscapedMarkdownInlineCode(branchName) + ) + '\n\n'; +} + +type AgentMessageOrEvent = IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent; + +/** + * Returns a copy of `messages` where `announcement` has been prepended to + * the first top-level assistant message's content. Subagent inner messages + * (those with a `parentToolCallId`) are skipped so the announcement lands + * on the parent turn. If no assistant message exists yet, returns the + * messages unchanged — the live announcement path is responsible for the + * very first turn before any reply has been recorded. + */ +function prependAnnouncementToFirstAssistantMessage( + messages: readonly AgentMessageOrEvent[], + announcement: string, +): readonly AgentMessageOrEvent[] { + const firstAssistantIdx = messages.findIndex(m => m.type === 'message' && m.role === 'assistant' && !m.parentToolCallId); + if (firstAssistantIdx === -1) { + return messages; + } + const target = messages[firstAssistantIdx] as IAgentMessageEvent; + const updated: IAgentMessageEvent = { ...target, content: announcement + target.content }; + return [ + ...messages.slice(0, firstAssistantIdx), + updated, + ...messages.slice(firstAssistantIdx + 1), + ]; +} + /** * Agent provider backed by the Copilot SDK {@link CopilotClient}. */ @@ -98,6 +142,16 @@ export class CopilotAgent extends Disposable implements IAgent { private _githubToken: string | undefined; private readonly _sessions = this._register(new DisposableMap()); private readonly _createdWorktrees = new Map(); + /** + * Per-session announcement (markdown string) that should be emitted as + * a synthetic streaming `delta` event the first time {@link sendMessage} + * is called for the session. Currently used to surface the "Created + * isolated worktree for branch X" message live during the first turn. + * The same announcement is also injected on restore via + * {@link getSessionMessages} by prepending to the first assistant + * message's content so it stays visible after the session is reopened. + */ + private readonly _pendingFirstTurnAnnouncements = new Map(); private readonly _sessionSequencer = new SequencerByKey(); private _shutdownPromise: Promise | undefined; private readonly _plugins: PluginController; @@ -597,6 +651,21 @@ export class CopilotAgent extends Disposable implements IAgent { } entry ??= await this._resumeSession(sessionId); + + // Emit any pending first-turn announcements (e.g. worktree + // created) as a synthetic streaming delta before delegating to + // the SDK. The mapper treats it like any other assistant text — + // the SDK's subsequent deltas append to the same markdown part. + // The active turn has already been started by the state manager + // at this point, so the event mapper can attach the delta to it. + const pending = this._pendingFirstTurnAnnouncements.get(sessionId); + if (pending) { + this._pendingFirstTurnAnnouncements.delete(sessionId); + const messageId = `copilot-announcement-${generateUuid()}`; + const event: IAgentDeltaEvent = { type: 'delta', session, messageId, content: pending }; + this._onDidSessionProgress.fire(event); + } + await entry.send(prompt, attachments, turnId); }); } @@ -628,7 +697,22 @@ export class CopilotAgent extends Disposable implements IAgent { if (!entry) { return []; } - return entry.getMessages(); + const rawMessages = await entry.getMessages(); + + // If a worktree was created for this session at create-time, prepend + // the announcement to the first assistant message's content so it + // appears at the top of the first response when the session is + // reopened. The live path (sendMessage) handles the very first turn + // when the session is fresh; this path takes over on subsequent + // loads, where _pendingFirstTurnAnnouncements is empty. + const branchName = await this._readWorktreeBranchMetadata(session).catch(err => { + this._logService.warn(`[Copilot:${sessionId}] Failed to read worktree branch metadata`, err); + return undefined; + }); + if (!branchName) { + return rawMessages; + } + return [...prependAnnouncementToFirstAssistantMessage(rawMessages, buildWorktreeAnnouncementText(branchName))]; } async disposeSession(session: URI): Promise { @@ -797,7 +881,7 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - private async _resumeSession(sessionId: string): Promise { + protected async _resumeSession(sessionId: string): Promise { this._logService.info(`[Copilot:${sessionId}] Session not in memory, resuming...`); const client = await this._ensureClient(); @@ -867,7 +951,7 @@ export class CopilotAgent extends Disposable implements IAgent { return this._gitService.getBranches(workingDirectory, { query, limit: CopilotAgent._BRANCH_COMPLETION_LIMIT }); } - private async _resolveSessionWorkingDirectory(config: IAgentCreateSessionConfig | undefined, sessionId: string): Promise { + protected async _resolveSessionWorkingDirectory(config: IAgentCreateSessionConfig | undefined, sessionId: string): Promise { if (config?.config?.isolation !== 'worktree' || !config.workingDirectory || typeof config.config.branch !== 'string') { return config?.workingDirectory; } @@ -884,6 +968,15 @@ export class CopilotAgent extends Disposable implements IAgent { await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); await this._gitService.addWorktree(repositoryRoot, worktree, branchName, config.config.branch); this._createdWorktrees.set(sessionId, { repositoryRoot, worktree }); + // Queue the worktree announcement so the first turn (live) and any + // subsequent restore (history) both surface the message in the chat. + this._pendingFirstTurnAnnouncements.set(sessionId, buildWorktreeAnnouncementText(branchName)); + const sessionUri = AgentSession.uri(this.id, sessionId); + try { + await this._writeWorktreeBranchMetadata(sessionUri, branchName); + } catch (error) { + this._logService.warn(`[Copilot:${sessionId}] Failed to persist worktree branch metadata: ${error instanceof Error ? error.message : String(error)}`); + } return worktree; } @@ -908,6 +1001,29 @@ export class CopilotAgent extends Disposable implements IAgent { private static readonly _META_PROJECT_RESOLVED = 'copilot.project.resolved'; private static readonly _META_PROJECT_URI = 'copilot.project.uri'; private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName'; + private static readonly _META_WORKTREE_BRANCH = 'copilot.worktree.branchName'; + + private async _writeWorktreeBranchMetadata(session: URI, branchName: string): Promise { + const dbRef = this._sessionDataService.openDatabase(session); + try { + await dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_BRANCH, branchName); + } finally { + dbRef.dispose(); + } + } + + private async _readWorktreeBranchMetadata(session: URI): Promise { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return undefined; + } + try { + const value = await ref.object.getMetadata(CopilotAgent._META_WORKTREE_BRANCH); + return value ?? undefined; + } finally { + ref.dispose(); + } + } private async _storeSessionMetadata(session: URI, model: IModelSelection | undefined, workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { const dbRef = this._sessionDataService.openDatabase(session); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 24e35d74a78..06b523a51de 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -5,6 +5,8 @@ import type { CopilotSession, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; import assert from 'assert'; +import * as fs from 'fs/promises'; +import * as os from 'os'; import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -16,7 +18,7 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type IAgentSessionMetadata } from '../../common/agentService.js'; +import { AgentSession, type IAgentDeltaEvent, type IAgentMessageEvent, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { ISessionCustomization, ICustomizationRef } from '../../common/state/sessionState.js'; @@ -40,13 +42,18 @@ class TestAgentPluginManager implements IAgentPluginManager { class TestAgentHostGitService implements IAgentHostGitService { declare readonly _serviceBrand: undefined; + repositoryRoot: URI | undefined = undefined; + addedWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string; startPoint: string }[] = []; + async isInsideWorkTree(): Promise { return false; } async getCurrentBranch(): Promise { return undefined; } async getDefaultBranch(): Promise { return undefined; } async getBranches(): Promise { return []; } - async getRepositoryRoot(): Promise { return undefined; } + async getRepositoryRoot(): Promise { return this.repositoryRoot; } async getWorktreeRoots(): Promise { return []; } - async addWorktree(): Promise { } + async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise { + this.addedWorktrees.push({ repositoryRoot, worktree, branchName, startPoint }); + } async removeWorktree(): Promise { } } @@ -114,6 +121,12 @@ class TestCopilotClient implements ICopilotClient { resumeSession: ICopilotClient['resumeSession'] = async () => { throw new Error('not implemented'); }; } +interface IFakeAgentSession { + send: (prompt: string, attachments?: unknown, turnId?: string) => Promise; + getMessages: () => Promise; + dispose: () => void; +} + class MockCopilotSession { readonly sessionId = 'test-session-1'; @@ -129,6 +142,9 @@ class MockCopilotSession { } class TestableCopilotAgent extends CopilotAgent { + private readonly _fakeSessions = new Map(); + readonly resumeCalls: string[] = []; + constructor( private readonly _copilotClient: ICopilotClient, @ILogService logService: ILogService, @@ -144,9 +160,35 @@ class TestableCopilotAgent extends CopilotAgent { protected override _createCopilotClient(): ICopilotClient { return this._copilotClient; } + + registerFakeSession(sessionId: string, fake: IFakeAgentSession): void { + this._fakeSessions.set(sessionId, fake); + } + + protected override async _resumeSession(sessionId: string): Promise { + this.resumeCalls.push(sessionId); + const fake = this._fakeSessions.get(sessionId); + if (!fake) { + throw new Error(`No fake session registered for '${sessionId}'`); + } + // `_sessions` is a DisposableMap, so it will dispose() the entry on + // teardown. The fields below are the only ones touched by sendMessage + // and getSessionMessages in the code under test. + const stub = { + send: fake.send, + getMessages: fake.getMessages, + appliedSnapshot: undefined, + dispose: fake.dispose, + } as unknown as CopilotAgentSession; + return stub; + } + + resolveWorktreeForTest(config: Parameters[0], sessionId: string): Promise { + return this._resolveSessionWorkingDirectory(config, sessionId); + } } -function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ICopilotClient; environmentServiceRegistration?: 'native' | 'none' }): { agent: CopilotAgent; instantiationService: IInstantiationService } { +function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ICopilotClient; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none' }): { agent: CopilotAgent; instantiationService: IInstantiationService } { const services = new ServiceCollection(); const logService = new NullLogService(); const fileService = disposables.add(new FileService(logService)); @@ -154,7 +196,7 @@ function createTestAgentContext(disposables: Pick, optio services.set(IFileService, fileService); services.set(ISessionDataService, options?.sessionDataService ?? createNullSessionDataService()); services.set(IAgentPluginManager, new TestAgentPluginManager()); - services.set(IAgentHostGitService, new TestAgentHostGitService()); + services.set(IAgentHostGitService, options?.gitService ?? new TestAgentHostGitService()); services.set(IAgentHostTerminalManager, new TestAgentHostTerminalManager()); if (options?.environmentServiceRegistration !== 'none') { const environmentService = { @@ -171,7 +213,7 @@ function createTestAgentContext(disposables: Pick, optio return { agent, instantiationService }; } -function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ICopilotClient; environmentServiceRegistration?: 'native' | 'none' }): CopilotAgent { +function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ICopilotClient; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none' }): CopilotAgent { return createTestAgentContext(disposables, options).agent; } @@ -340,4 +382,146 @@ suite('CopilotAgent', () => { await disposeAgent(agent); } }); + + suite('worktree announcement', () => { + // Drives a real session through worktree creation (calling the + // agent's _resolveSessionWorkingDirectory via a test seam so we don't + // need a full Copilot SDK), then exercises both the live path + // (sendMessage emits a synthetic delta) and the restore path + // (getSessionMessages prepends to the first assistant message). A + // stubbed CopilotAgentSession is injected via overriding _resumeSession + // because the real one requires a full SDK CopilotSession with ~30 + // event subscriptions. + + let tmpDir: string; + + setup(async () => { + tmpDir = await fs.mkdtemp(`${os.tmpdir()}/copilot-agent-worktree-test-`); + }); + + teardown(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + test('emits announcement live as a delta on first sendMessage and persists it for restore via getSessionMessages', async () => { + const sessionId = 'wt-session'; + const session = AgentSession.uri('copilot', sessionId); + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { + sessionDataService, + copilotClient: new TestCopilotClient([]), + gitService, + }) as TestableCopilotAgent; + + const fakeMessages: (IAgentMessageEvent | IAgentToolStartEvent)[] = [ + { session, type: 'message', role: 'user', messageId: 'u1', content: 'hi' }, + { session, type: 'message', role: 'assistant', messageId: 'a1', content: 'hello back' }, + ]; + let sendCalls = 0; + agent.registerFakeSession(sessionId, { + send: async () => { sendCalls++; }, + getMessages: async () => fakeMessages, + dispose: () => { }, + }); + + try { + await agent.authenticate('https://api.github.com', 'token'); + + // 1. Drive worktree resolution: this is what createSession does + // before constructing the SDK session. Verifies that the + // real production code path persists branch metadata and + // queues the live announcement. + const branchHint = 'add-feature'; + const expectedBranchName = getCopilotWorktreeBranchName(sessionId, branchHint); + const workingDir = await agent.resolveWorktreeForTest({ + workingDirectory: repositoryRoot, + config: { isolation: 'worktree', branch: 'main', branchNameHint: branchHint }, + }, sessionId); + assert.ok(workingDir, 'resolveWorktreeForTest must return a worktree URI'); + assert.deepStrictEqual(gitService.addedWorktrees.length, 1, 'addWorktree must be called once'); + assert.strictEqual(gitService.addedWorktrees[0].branchName, expectedBranchName); + + // 2. Live path: sendMessage must fire a synthetic delta event + // carrying the announcement text before delegating to the SDK. + const events: IAgentProgressEvent[] = []; + disposables.add(agent.onDidSessionProgress(e => events.push(e))); + + await agent.sendMessage(session, 'hello'); + assert.strictEqual(sendCalls, 1, 'underlying SDK send must still be called'); + + const deltas = events.filter((e): e is IAgentDeltaEvent => e.type === 'delta'); + assert.strictEqual(deltas.length, 1, 'exactly one delta should be emitted for the worktree announcement'); + const announcement = deltas[0]; + assert.ok(announcement.content.includes(expectedBranchName), `announcement should contain branch name '${expectedBranchName}', got '${announcement.content}'`); + assert.ok(announcement.messageId.startsWith('copilot-announcement-'), `announcement messageId should be synthetic, got '${announcement.messageId}'`); + + // 3. Live path is one-shot: a second sendMessage must not re-emit. + events.length = 0; + await agent.sendMessage(session, 'follow-up'); + assert.strictEqual(events.filter(e => e.type === 'delta').length, 0, 'announcement must not be re-emitted on subsequent sends'); + + // 4. Restore path: getSessionMessages must prepend the + // announcement to the first assistant message's content, + // using the persisted branch metadata. + const restored = await agent.getSessionMessages(session); + const assistant = restored.find((m): m is IAgentMessageEvent => m.type === 'message' && m.role === 'assistant'); + assert.ok(assistant, 'restored messages should include the assistant reply'); + assert.ok(assistant.content.includes(expectedBranchName), `restored assistant content should include the branch name, got '${assistant.content}'`); + assert.ok(assistant.content.endsWith('hello back'), `restored assistant content should still end with the original reply, got '${assistant.content}'`); + } finally { + await disposeAgent(agent); + } + }); + + test('does not announce or persist branch metadata when isolation is not worktree', async () => { + const sessionId = 'no-wt-session'; + const session = AgentSession.uri('copilot', sessionId); + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { + sessionDataService, + copilotClient: new TestCopilotClient([]), + gitService, + }) as TestableCopilotAgent; + + const fakeMessages: IAgentMessageEvent[] = [ + { session, type: 'message', role: 'user', messageId: 'u1', content: 'hi' }, + { session, type: 'message', role: 'assistant', messageId: 'a1', content: 'untouched reply' }, + ]; + agent.registerFakeSession(sessionId, { + send: async () => { }, + getMessages: async () => fakeMessages, + dispose: () => { }, + }); + + try { + await agent.authenticate('https://api.github.com', 'token'); + + await agent.resolveWorktreeForTest({ workingDirectory: repositoryRoot }, sessionId); + assert.deepStrictEqual(gitService.addedWorktrees, [], 'addWorktree must not be called without worktree isolation'); + + const events: IAgentProgressEvent[] = []; + disposables.add(agent.onDidSessionProgress(e => events.push(e))); + await agent.sendMessage(session, 'hello'); + assert.deepStrictEqual(events.filter(e => e.type === 'delta'), [], 'no announcement should be emitted live'); + + const restored = await agent.getSessionMessages(session); + const assistant = restored.find((m): m is IAgentMessageEvent => m.type === 'message' && m.role === 'assistant'); + assert.strictEqual(assistant?.content, 'untouched reply', 'restored assistant content must not be modified'); + } finally { + await disposeAgent(agent); + } + }); + }); }); From 214d51ca6fb8867efee3e47b79aaecc12dc5e9f8 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:33:32 -0400 Subject: [PATCH 007/114] Fix notebook style resets Fixes #311100 `left` is used for the moving off screen so we need to reapply it when showing the notebook again Co-authored-by: Copilot --- src/vs/base/browser/overlayLayoutElement.ts | 9 +++++++-- .../contrib/notebook/browser/notebookEditorWidget.ts | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/vs/base/browser/overlayLayoutElement.ts b/src/vs/base/browser/overlayLayoutElement.ts index 588d4ce9b33..c6e764dda6d 100644 --- a/src/vs/base/browser/overlayLayoutElement.ts +++ b/src/vs/base/browser/overlayLayoutElement.ts @@ -53,6 +53,13 @@ export class OverlayLayoutElement implements IDisposable { this.content.style.position = 'absolute'; this.content.style.overflow = 'hidden'; + this._root = document.createElement('div'); + this._root.appendChild(this.content); + + this.reapplyLayoutStyles(); + } + + public reapplyLayoutStyles(): void { if (supportsAnchorPositioning.value) { this.content.style.position = 'fixed'; this.content.style.top = 'anchor(top)'; @@ -62,10 +69,8 @@ export class OverlayLayoutElement implements IDisposable { this.content.style.pointerEvents = 'auto'; } - this._root = document.createElement('div'); this._root.style.position = 'absolute'; this._root.style.pointerEvents = 'none'; - this._root.appendChild(this.content); } public dispose(): void { diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 2c6b5576178..4cb3e8b2258 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -331,7 +331,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this.isReplHistory = creationOptions.isReplHistory ?? false; this._readOnly = creationOptions.isReadOnly ?? false; - this._overlayLayout = new OverlayLayoutElement(); + this._overlayLayout = this._register(new OverlayLayoutElement()); this._overlayContainer = this._overlayLayout.content; this.scopedContextKeyService = this._register(contextKeyService.createScoped(this._overlayContainer)); this.instantiationService = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); @@ -1953,8 +1953,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD } this._overlayContainer.style.visibility = 'visible'; - this._overlayContainer.style.left = ''; // Clear hide offset this._overlayLayout.layoutOverAnchorElement(shadowElement, { clippingContainer, fallbackDimension: dimension, fallbackPosition: position }); + this._overlayLayout.reapplyLayoutStyles(); } //#endregion From a1c44a69c436e13172693438eb8f53bf72a84b60 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 19 Apr 2026 14:04:08 -0700 Subject: [PATCH 008/114] Agents: extract BaseAgentHostSessionsProvider to share local/remote logic (#311261) * Agents: extract BaseAgentHostSessionsProvider to share local/remote logic (Written by Copilot) Extracts the shared session/config/adapter/notification flow from LocalAgentHostSessionsProvider and RemoteAgentHostSessionsProvider into a new abstract BaseAgentHostSessionsProvider plus a single concrete AgentHostSessionAdapter (with an options bag for variation points), both under src/vs/sessions/contrib/agentHost/browser/. The local provider drops from 1164 to 186 LOC. The remote provider drops from 1457 to 395 LOC, retaining the connection-lifecycle surface (setConnection / clearConnection / setAuthenticationPending / setConnectionStatus / setOutputChannelId), the well-known agent-type mapping, and the remote folder picker. Public API and behavior are unchanged; both providers' existing test suites pass unmodified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Agents: merge localAgentHost contrib into agentHost contrib (Written by Copilot) The base provider already lives in src/vs/sessions/contrib/agentHost/. Move the local provider, contribution, and test in alongside it so the local agent host and the shared base sit in one folder. The remote provider stays in its own contrib because it carries substantially more machinery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clear _currentNewSessionStatus alongside other draft state in createNewSession (Written by Copilot) Addresses Copilot review feedback: when createNewSession throws before _createNewSessionForType runs (no matching sessionType, validation failure), the previous draft's status observable would otherwise be left around. Reset it with the rest of the draft state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/i18n.resources.json | 8 +- .../common/agentHostSessionsProvider.ts | 21 + .../browser/baseAgentHostSessionsProvider.ts} | 988 ++++++------ .../browser/localAgentHost.contribution.ts | 0 .../browser/localAgentHostSessionsProvider.ts | 186 +++ .../localAgentHostSessionsProvider.test.ts | 0 .../remoteAgentHostSessionsProvider.ts | 1360 ++--------------- src/vs/sessions/sessions.desktop.main.ts | 2 +- 8 files changed, 852 insertions(+), 1713 deletions(-) rename src/vs/sessions/contrib/{localAgentHost/browser/localAgentHostSessionsProvider.ts => agentHost/browser/baseAgentHostSessionsProvider.ts} (69%) rename src/vs/sessions/contrib/{localAgentHost => agentHost}/browser/localAgentHost.contribution.ts (100%) create mode 100644 src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts rename src/vs/sessions/contrib/{localAgentHost => agentHost}/test/browser/localAgentHostSessionsProvider.test.ts (100%) diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 960c4f10529..e04a38c9fbe 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -636,6 +636,10 @@ "name": "vs/sessions/contrib/agentFeedback", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/agentHost", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/aiCustomizationTreeView", "project": "vscode-sessions" @@ -684,10 +688,6 @@ "name": "vs/sessions/contrib/logs", "project": "vscode-sessions" }, - { - "name": "vs/sessions/contrib/localAgentHost", - "project": "vscode-sessions" - }, { "name": "vs/sessions/contrib/remoteAgentHost", "project": "vscode-sessions" diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index caf58ac6607..16dc8ea7c26 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -80,3 +80,24 @@ export function resolvedConfigsEqual(a: IResolveSessionConfigResult, b: IResolve } return true; } + +/** Known auto-approve config values. */ +const AUTO_APPROVE_ENUM = ['default', 'autoApprove', 'autopilot']; + +/** + * Builds a minimal session-mutable config schema from changed values. + * Used when a restored session receives a ConfigChanged action before + * the full schema has been hydrated. + */ +export function buildMutableConfigSchema(config: Record): Record { + const properties: Record = {}; + for (const key of Object.keys(config)) { + properties[key] = { + type: 'string', + title: key, + sessionMutable: true, + enum: key === 'autoApprove' ? AUTO_APPROVE_ENUM : [config[key]], + }; + } + return properties; +} diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts similarity index 69% rename from src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts rename to src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index bb7556841ea..77b2797d5c8 100644 --- a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -5,82 +5,67 @@ import { raceTimeout } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; import { constObservable, derived, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; -import { basename } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; -import { AgentSession, IAgentHostService, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; -import type { IFileEdit, IModelSelection, IRootState, ISessionConfigPropertySchema, ISessionState, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; +import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; +import { IResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { IFileEdit, IModelSelection, ISessionConfigPropertySchema, ISessionState, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; -import type { IResolveSessionConfigResult, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; -import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatSendRequestOptions, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { agentHostSessionWorkspaceKey, buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; -import { isSessionConfigComplete } from '../../../common/sessionConfig.js'; +import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWorkspace.js'; import { diffsToChanges, diffsEqual, mapProtocolStatus } from '../../../common/agentHostDiffs.js'; -import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; +import { buildMutableConfigSchema, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../common/agentHostSessionsProvider.js'; +import { isSessionConfigComplete } from '../../../common/sessionConfig.js'; import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js'; -import { IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../common/agentHostSessionsProvider.js'; -import { IChat, ISession, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, type IGitHubInfo, ISessionType } from '../../../services/sessions/common/session.js'; +import { IChat, IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus } from '../../../services/sessions/common/session.js'; -const LOCAL_PROVIDER_ID = 'local-agent-host'; +// ============================================================================ +// AgentHostSessionAdapter — shared adapter for local and remote sessions +// ============================================================================ + +/** + * Variation points the host provider supplies when building an adapter. + * Differences between local and remote sessions (icon, description text, + * workspace builder, optional URI mapping) flow through this options bag so + * the adapter itself stays a single concrete class. + */ +export interface IAgentHostAdapterOptions { + readonly icon: ThemeIcon; + readonly description: IMarkdownString; + /** Loading observable wired to the provider's authentication-pending state. */ + readonly loading: IObservable; + /** Builds the session workspace from session metadata; provider-specific (icon, providerLabel, requiresWorkspaceTrust). */ + readonly buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined) => ISessionWorkspace | undefined; + /** Optional URI mapping for diff entries (remote uses `toAgentHostUri`; local uses identity). */ + readonly mapDiffUri?: (uri: URI) => URI; +} -/** Default provider when session metadata does not carry one. */ const DEFAULT_AGENT_PROVIDER = 'copilot'; -/** Known auto-approve config values. */ -const AUTO_APPROVE_ENUM = ['default', 'autoApprove', 'autopilot']; - /** - * Builds a minimal session-mutable config schema from changed values. - * Used when a restored session receives a ConfigChanged action before - * the full schema has been hydrated. + * Adapts an {@link IAgentSessionMetadata} into an {@link ISession} for the + * sessions UI. A single concrete class for both local and remote agent + * hosts — variation flows through {@link IAgentHostAdapterOptions}. */ -function buildMutableConfigSchema(config: Record): Record { - const properties: Record = {}; - for (const key of Object.keys(config)) { - properties[key] = { - type: 'string', - title: key, - sessionMutable: true, - enum: key === 'autoApprove' ? AUTO_APPROVE_ENUM : [config[key]], - }; - } - return properties; -} - -/** - * Derives the session type / URI scheme from an agent provider name. - * Must match the type string registered by AgentHostContribution - * (`agent-host-${agent.provider}`). - */ -function sessionTypeForProvider(provider: string): string { - return `agent-host-${provider}`; -} - -/** - * Adapts agent host session metadata into an {@link ISession} for the - * local agent host. Also exposes settable observables so the cache - * layer can push live updates. - */ -class LocalSessionAdapter implements ISession { +export class AgentHostSessionAdapter implements ISession { readonly sessionId: string; readonly resource: URI; readonly providerId: string; readonly sessionType: string; - readonly icon = Codicon.vm; + readonly icon: ThemeIcon; readonly createdAt: Date; readonly workspace: ISettableObservable; readonly title: ISettableObservable; @@ -108,7 +93,7 @@ class LocalSessionAdapter implements ISession { providerId: string, resourceScheme: string, logicalSessionType: string, - authenticationPending: IObservable, + private readonly _options: IAgentHostAdapterOptions, ) { const rawId = AgentSession.id(metadata.session); this.agentProvider = AgentSession.provider(metadata.session) ?? DEFAULT_AGENT_PROVIDER; @@ -116,18 +101,17 @@ class LocalSessionAdapter implements ISession { this.sessionId = `${providerId}:${this.resource.toString()}`; this.providerId = providerId; this.sessionType = logicalSessionType; + this.icon = _options.icon; this.createdAt = new Date(metadata.startTime); this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`); this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); this.modelSelection = metadata.model; this.status = observableValue('status', metadata.status !== undefined ? mapProtocolStatus(metadata.status) : SessionStatus.Completed); - this.modelId = observableValue('modelId', metadata.model ? `${logicalSessionType}:${metadata.model.id}` : undefined); + this.modelId = observableValue('modelId', metadata.model ? `${resourceScheme}:${metadata.model.id}` : undefined); this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); - this.description = observableValue('description', new MarkdownString().appendText(localize('localAgentHostDescription', "Local"))); - this.workspace = observableValue('workspace', LocalAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory)); - // Cached sessions surface as loading while the local agent host is still - // authenticating. They have no per-session loading state of their own. - this.loading = authenticationPending; + this.description = observableValue('description', _options.description); + this.workspace = observableValue('workspace', _options.buildWorkspace(metadata.project, metadata.workingDirectory)); + this.loading = _options.loading; if (metadata.isRead === false) { this.isRead.set(false, undefined); @@ -136,7 +120,7 @@ class LocalSessionAdapter implements ISession { this.isArchived.set(true, undefined); } if (metadata.diffs && metadata.diffs.length > 0) { - this.changes.set(diffsToChanges(metadata.diffs), undefined); + this.changes.set(diffsToChanges(metadata.diffs, _options.mapDiffUri), undefined); } this.mainChat = { @@ -156,6 +140,10 @@ class LocalSessionAdapter implements ISession { this.chats = constObservable([this.mainChat]); } + /** + * Update fields from a refreshed metadata snapshot. Returns `true` iff + * any user-visible field changed. + */ update(metadata: IAgentSessionMetadata): boolean { let didChange = false; @@ -186,7 +174,7 @@ class LocalSessionAdapter implements ISession { didChange = true; } - const workspace = LocalAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory); + const workspace = this._options.buildWorkspace(metadata.project, metadata.workingDirectory); if (agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get())) { this.workspace.set(workspace, undefined); didChange = true; @@ -203,14 +191,14 @@ class LocalSessionAdapter implements ISession { } this.modelSelection = metadata.model; - const modelId = metadata.model ? `${this.sessionType}:${metadata.model.id}` : undefined; + const modelId = metadata.model ? `${this.resource.scheme}:${metadata.model.id}` : undefined; if (modelId !== this.modelId.get()) { this.modelId.set(modelId, undefined); didChange = true; } - if (metadata.diffs && !diffsEqual(this.changes.get(), metadata.diffs)) { - this.changes.set(diffsToChanges(metadata.diffs), undefined); + if (metadata.diffs && !diffsEqual(this.changes.get(), metadata.diffs, this._options.mapDiffUri)) { + this.changes.set(diffsToChanges(metadata.diffs, this._options.mapDiffUri), undefined); didChange = true; } @@ -218,66 +206,70 @@ class LocalSessionAdapter implements ISession { } } +// ============================================================================ +// BaseAgentHostSessionsProvider — shared base for local and remote providers +// ============================================================================ + /** - * Sessions provider for the local agent host. + * Shared base class for the local and remote agent host sessions providers. * - * Implements {@link ISessionsProvider} to surface local agent host sessions - * in the Sessions app's session list, workspace picker, and session management UI. + * Owns the structures and flows that are identical between the two: + * the session cache, the new-session/running-session config picker state, + * the lazy session-state subscriptions, the AHP notification/action + * handlers, and every connection-routed method (set/get/archive/delete/ + * rename/setModel/sendAndCreateChat). * - * The heavy lifting (agent discovery, session handlers, language model providers, - * customization harness) is handled by the existing {@link AgentHostContribution} - * which is already active in the Sessions app. This provider only bridges the - * session listing and lifecycle to the {@link ISessionsProvidersService} layer. - * - * **URI/ID scheme:** - * - **rawId** - unique session identifier (e.g. `abc123`), used as the cache key. - * - **resource** - `agent-host-{provider}:///{rawId}` (e.g. `agent-host-copilot:///abc123`). - * The scheme routes the chat service to the correct {@link AgentHostSessionHandler}. - * - **sessionId** - `local-agent-host:agent-host-{provider}:///{rawId}` — the - * provider-scoped ID used by {@link ISessionsProvider}. + * Subclasses supply the genuine variation points: the connection + * accessor, the authentication-pending observable, an adapter factory, + * URI-scheme mapping for session metadata, the agent-provider lookup, and + * the browse UI. */ -export class LocalAgentHostSessionsProvider extends Disposable implements IAgentHostSessionsProvider { +export abstract class BaseAgentHostSessionsProvider extends Disposable implements IAgentHostSessionsProvider { - readonly id = LOCAL_PROVIDER_ID; - readonly label: string; - readonly icon: ThemeIcon = Codicon.vm; - private readonly _localLabel = localize('localAgentHostSessionTypeLocation', "Local"); - private _hasRootStateSnapshot = false; - private _sessionTypes: ISessionType[] = []; - get sessionTypes(): readonly ISessionType[] { - const rootStateValue = this._agentHostService.rootState.value; - return this._hasRootStateSnapshot || rootStateValue !== undefined ? this._sessionTypes : this._getSessionTypesFromContributions(); - } + abstract readonly id: string; + abstract readonly label: string; + abstract readonly icon: ThemeIcon; + abstract readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; - private readonly _onDidChangeSessionTypes = this._register(new Emitter()); + get sessionTypes(): readonly ISessionType[] { return this._sessionTypes; } + protected _sessionTypes: ISessionType[] = []; + + protected readonly _onDidChangeSessionTypes = this._register(new Emitter()); readonly onDidChangeSessionTypes: Event = this._onDidChangeSessionTypes.event; - readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; - - private readonly _onDidChangeSessions = this._register(new Emitter()); + protected readonly _onDidChangeSessions = this._register(new Emitter()); readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; - private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); + protected readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; - private readonly _onDidChangeSessionConfig = this._register(new Emitter()); + + protected readonly _onDidChangeSessionConfig = this._register(new Emitter()); readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event; /** Cache of adapted sessions, keyed by raw session ID. */ - private readonly _sessionCache = new Map(); + protected readonly _sessionCache = new Map(); - private _pendingSession: ISession | undefined; - private _selectedModelId: string | undefined; - private _currentNewSession: ISession | undefined; - private _currentNewSessionStatus: ISettableObservable | undefined; - private _currentNewSessionModelId: ISettableObservable | undefined; - private _currentNewSessionLoading: ISettableObservable | undefined; - private readonly _newSessionWorkspaces = new Map(); - private readonly _newSessionConfigs = new Map(); - private readonly _newSessionAgentProviders = new Map(); - private readonly _newSessionConfigRequests = new Map(); + /** + * Temporary session that has been sent (first turn dispatched) but not yet + * committed to a real backend session. Shown in the session list until the + * server creates the backend session, at which point it is replaced via + * {@link _onDidReplaceSession}. + */ + protected _pendingSession: ISession | undefined; + + protected _currentNewSession: ISession | undefined; + protected _currentNewSessionStatus: ISettableObservable | undefined; + protected _currentNewSessionModelId: ISettableObservable | undefined; + protected _currentNewSessionLoading: ISettableObservable | undefined; + protected _selectedModelId: string | undefined; + + protected readonly _newSessionWorkspaces = new Map(); + protected readonly _newSessionConfigs = new Map(); + protected readonly _newSessionAgentProviders = new Map(); + protected readonly _newSessionConfigRequests = new Map(); /** Config for running sessions (session-mutable properties only), keyed by session ID. */ - private readonly _runningSessionConfigs = new Map(); + protected readonly _runningSessionConfigs = new Map(); /** * Lazy session-state subscriptions used to seed {@link _runningSessionConfigs} @@ -285,123 +277,52 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent * window). The underlying wire subscription is reference-counted by * {@link IAgentConnection.getSubscription}, so when the session handler is * also subscribed (i.e. chat content is loaded) no extra wire subscribe is - * issued. Keyed by session ID. Each entry owns the subscription `IReference` - * plus the `onDidChange` listener. + * issued. Keyed by session ID. */ - private readonly _sessionStateSubscriptions = this._register(new DisposableMap()); + protected readonly _sessionStateSubscriptions = this._register(new DisposableMap()); - private _cacheInitialized = false; + protected _cacheInitialized = false; constructor( - @IAgentHostService private readonly _agentHostService: IAgentHostService, - @IFileDialogService private readonly _fileDialogService: IFileDialogService, - @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, - @IChatService private readonly _chatService: IChatService, - @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, - @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IChatSessionsService protected readonly _chatSessionsService: IChatSessionsService, + @IChatService protected readonly _chatService: IChatService, + @IChatWidgetService protected readonly _chatWidgetService: IChatWidgetService, + @ILanguageModelsService protected readonly _languageModelsService: ILanguageModelsService, ) { super(); - - this.label = localize('localAgentHostLabel', "Local Agent Host"); - - this.browseActions = [{ - label: localize('folders', "Folders"), - icon: Codicon.folderOpened, - providerId: this.id, - run: () => this._browseForFolder(), - }]; - - // Listen for notifications from the agent host to update the session list - this._register(this._agentHostService.onDidNotification(n => { - if (n.type === NotificationType.SessionAdded) { - this._handleSessionAdded(n.summary); - } else if (n.type === NotificationType.SessionRemoved) { - this._handleSessionRemoved(n.session); - } else if (n.type === NotificationType.SessionSummaryChanged) { - this._handleSessionSummaryChanged(n.session, n.changes); - } - })); - - this._register(this._agentHostService.onDidAction(e => { - if (e.action.type === ActionType.SessionTurnComplete && isSessionAction(e.action)) { - this._refreshSessions(); - } else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) { - this._handleTitleChanged(e.action.session, e.action.title); - } else if (e.action.type === ActionType.SessionModelChanged && isSessionAction(e.action)) { - this._handleModelChanged(e.action.session, e.action.model); - } else if (e.action.type === ActionType.SessionIsReadChanged && isSessionAction(e.action)) { - this._handleIsReadChanged(e.action.session, e.action.isRead); - } else if (e.action.type === ActionType.SessionIsDoneChanged && isSessionAction(e.action)) { - this._handleIsDoneChanged(e.action.session, e.action.isDone); - } else if (e.action.type === ActionType.SessionConfigChanged && isSessionAction(e.action)) { - this._handleConfigChanged(e.action.session, e.action.config); - } else if (e.action.type === ActionType.SessionDiffsChanged && isSessionAction(e.action)) { - this._handleDiffsChanged(e.action.session, e.action.diffs); - } - })); - - const rootStateValue = this._agentHostService.rootState.value; - if (rootStateValue !== undefined) { - this._hasRootStateSnapshot = true; - } - if (rootStateValue && !(rootStateValue instanceof Error)) { - this._syncSessionTypesFromRootState(rootStateValue); - } - this._register(this._agentHostService.rootState.onDidChange(rootState => { - const didHydrate = !this._hasRootStateSnapshot; - this._hasRootStateSnapshot = true; - this._syncSessionTypesFromRootState(rootState, didHydrate); - })); } - private _syncSessionTypesFromRootState(rootState: IRootState, forceFire = false): void { - const next = rootState.agents.map((agent): ISessionType => ({ - id: sessionTypeForProvider(agent.provider), - label: this._formatSessionTypeLabel(agent.displayName || agent.provider), - icon: Codicon.vm, - })); + // -- Subclass hooks ------------------------------------------------------- - const prev = this._sessionTypes; - if (!forceFire && prev.length === next.length && prev.every((t, i) => t.id === next[i].id && t.label === next[i].label)) { - return; - } - this._sessionTypes = next; - this._onDidChangeSessionTypes.fire(); - } + /** Current connection (always present for local; may be undefined while disconnected for remote). */ + protected abstract get connection(): IAgentConnection | undefined; - private _formatSessionTypeLabel(agentLabel: string): string { - return localize('localAgentHostSessionType', "{0} [{1}]", agentLabel, this._localLabel); - } + /** Provider-level authentication-pending observable used to derive `loading` for sessions. */ + protected abstract get authenticationPending(): IObservable; - private _getSessionTypesFromContributions(): ISessionType[] { - return this._chatSessionsService.getAllChatSessionContributions() - .filter(contribution => contribution.type.startsWith('agent-host-')) - .map((contribution): ISessionType => ({ - id: contribution.type, - label: this._formatSessionTypeLabel(contribution.displayName), - icon: Codicon.vm, - })); - } + /** Build an adapter for the given metadata. Subclass picks resource scheme, logical type, and adapter options. */ + protected abstract createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter; - // -- Workspaces -- + /** Resolve a UI session-type id to the URI scheme used for new session resources. */ + protected abstract resourceSchemeForSessionType(sessionTypeId: string): string; - static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined): ISessionWorkspace | undefined { - return buildAgentHostSessionWorkspace(project, workingDirectory, { fallbackIcon: Codicon.folder, requiresWorkspaceTrust: true }); - } + /** Reverse of {@link resourceSchemeForSessionType} for the agent provider name. */ + protected abstract agentProviderFromSessionType(sessionType: string): string; - resolveWorkspace(repositoryUri: URI): ISessionWorkspace { - const folderName = basename(repositoryUri) || repositoryUri.path; - return { - label: folderName, - icon: Codicon.folder, - repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], - requiresWorkspaceTrust: true, - }; - } + abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace; - // -- Sessions -- + /** Optional event fired when the underlying connection is lost; used to short-circuit `_waitForNewSession`. */ + protected get onConnectionLost(): Event { return Event.None; } - getSessionTypes(repositoryUri: URI): ISessionType[] { + /** Maps a working-directory URI from the session summary to a local URI. Default identity; remote overrides to `toAgentHostUri`. */ + protected mapWorkingDirectoryUri(uri: URI): URI { return uri; } + + /** Maps a project URI from the session summary to a local URI. Default identity; remote overrides for `file:` paths. */ + protected mapProjectUri(uri: URI): URI { return uri; } + + // -- Session listing ------------------------------------------------------ + + getSessionTypes(_repositoryUri: URI): ISessionType[] { return [...this.sessionTypes]; } @@ -433,7 +354,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent return undefined; } - // -- Session Lifecycle -- + // -- Session lifecycle ---------------------------------------------------- createNewSession(workspaceUri: URI, sessionTypeId: string): ISession { if (!workspaceUri) { @@ -447,23 +368,35 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent this._selectedModelId = undefined; this._currentNewSessionModelId = undefined; this._currentNewSessionLoading = undefined; + this._currentNewSessionStatus = undefined; const sessionType = this.sessionTypes.find(t => t.id === sessionTypeId); if (!sessionType) { - throw new Error(localize('noAgents', "Local agent host has not advertised any agents yet.")); + throw new Error(this._noAgentsErrorMessage()); } + this._validateBeforeCreate(sessionType); + const workspace = this.resolveWorkspace(workspaceUri); return this._createNewSessionForType(workspace, sessionType); } + /** Subclass hook for additional pre-create checks (e.g. remote requires connection). */ + protected _validateBeforeCreate(_sessionType: ISessionType): void { /* default: no-op */ } + + /** Localized "no agents" error message. Subclasses can override. */ + protected _noAgentsErrorMessage(): string { + return localize('noAgents', "Agent host has not advertised any agents yet."); + } + private _createNewSessionForType(workspace: ISessionWorkspace, sessionType: ISessionType): ISession { const workspaceUri = workspace.repositories[0]?.uri; if (!workspaceUri) { throw new Error('Workspace has no repository URI'); } - const resource = URI.from({ scheme: sessionType.id, path: `/untitled-${generateUuid()}` }); + const resourceScheme = this.resourceSchemeForSessionType(sessionType.id); + const resource = URI.from({ scheme: resourceScheme, path: `/untitled-${generateUuid()}` }); const status = observableValue(this, SessionStatus.Untitled); const title = observableValue(this, ''); const updatedAt = observableValue(this, new Date()); @@ -482,12 +415,13 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent changes, modelId, mode, isArchived, isRead, description, lastTurnEnd, }; + const authPending = this.authenticationPending; const session: ISession = { sessionId: `${this.id}:${resource.toString()}`, resource, providerId: this.id, sessionType: sessionType.id, - icon: Codicon.vm, + icon: this.icon, createdAt, workspace: observableValue(this, workspace), title, @@ -496,7 +430,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent changes, modelId, mode, - loading: derived(reader => loading.read(reader) || this._agentHostService.authenticationPending.read(reader)), + loading: derived(reader => loading.read(reader) || authPending.read(reader)), isArchived, isRead, description, @@ -510,7 +444,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent this._currentNewSessionStatus = status; this._currentNewSessionModelId = modelId; this._currentNewSessionLoading = loading; - const agentProvider = this._agentProviderFromSessionType(sessionType.id); + const agentProvider = this.agentProviderFromSessionType(sessionType.id); this._newSessionWorkspaces.set(session.sessionId, workspaceUri); this._newSessionAgentProviders.set(session.sessionId, agentProvider); this._newSessionConfigs.set(session.sessionId, { schema: { type: 'object', properties: {} }, values: {} }); @@ -519,6 +453,8 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent return session; } + // -- Dynamic session config ---------------------------------------------- + getSessionConfig(sessionId: string): IResolveSessionConfigResult | undefined { // New-session config wins (during pre-creation flow). Otherwise lazily // subscribe to the session's state so the running picker can seed its @@ -546,7 +482,8 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent // Running session: dispatch SessionConfigChanged for sessionMutable properties const runningConfig = this._runningSessionConfigs.get(sessionId); - if (!runningConfig) { + const connection = this.connection; + if (!runningConfig || !connection) { return; } const schema = runningConfig.schema.properties[property]; @@ -566,16 +503,17 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (cached && rawId) { const action = { type: ActionType.SessionConfigChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), config: { [property]: value } }; - this._agentHostService.dispatch(action); + connection.dispatch(action); } } - async getSessionConfigCompletions(sessionId: string, property: string, query?: string): Promise { + async getSessionConfigCompletions(sessionId: string, property: string, query?: string) { const workingDirectory = this._newSessionWorkspaces.get(sessionId); - if (!workingDirectory) { + const connection = this.connection; + if (!workingDirectory || !connection) { return []; } - const result = await this._agentHostService.sessionConfigCompletions({ + const result = await connection.sessionConfigCompletions({ provider: this._getAgentProviderForSession(sessionId), workingDirectory, config: this._newSessionConfigs.get(sessionId)?.values, @@ -593,6 +531,8 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent this._clearNewSessionConfig(sessionId); } + // -- Model selection ------------------------------------------------------ + setModel(sessionId: string, modelId: string): void { if (this._currentNewSession?.sessionId === sessionId) { this._selectedModelId = modelId; @@ -602,17 +542,19 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent const rawId = this._rawIdFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (cached && rawId) { + const connection = this.connection; + if (cached && rawId && connection) { cached.modelId.set(modelId, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); - const rawModelId = modelId.startsWith(`${cached.sessionType}:`) ? modelId.substring(cached.sessionType.length + 1) : modelId; + const resourceScheme = cached.resource.scheme; + const rawModelId = modelId.startsWith(`${resourceScheme}:`) ? modelId.substring(resourceScheme.length + 1) : modelId; const model = cached.modelSelection?.id === rawModelId ? cached.modelSelection : { id: rawModelId }; const action = { type: ActionType.SessionModelChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), model }; - this._agentHostService.dispatch(action); + connection.dispatch(action); } } - // -- Session Actions -- + // -- Session actions ------------------------------------------------------ async archiveSession(sessionId: string): Promise { const rawId = this._rawIdFromChatId(sessionId); @@ -620,8 +562,11 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent if (cached && rawId) { cached.isArchived.set(true, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); - const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: true }; - this._agentHostService.dispatch(action); + const connection = this.connection; + if (connection) { + const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: true }; + connection.dispatch(action); + } } } @@ -631,16 +576,20 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent if (cached && rawId) { cached.isArchived.set(false, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); - const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: false }; - this._agentHostService.dispatch(action); + const connection = this.connection; + if (connection) { + const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: false }; + connection.dispatch(action); + } } } async deleteSession(sessionId: string): Promise { const rawId = this._rawIdFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (cached && rawId) { - await this._agentHostService.disposeSession(AgentSession.uri(cached.agentProvider, rawId)); + const connection = this.connection; + if (cached && rawId && connection) { + await connection.disposeSession(AgentSession.uri(cached.agentProvider, rawId)); this._sessionCache.delete(rawId); this._runningSessionConfigs.delete(sessionId); this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] }); @@ -650,11 +599,12 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent async renameChat(sessionId: string, _chatUri: URI, title: string): Promise { const rawId = this._rawIdFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (cached && rawId) { + const connection = this.connection; + if (cached && rawId && connection) { cached.title.set(title, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); const action = { type: ActionType.SessionTitleChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), title }; - this._agentHostService.dispatch(action); + connection.dispatch(action); } } @@ -662,7 +612,20 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent // Agent host sessions don't support deleting individual chats } + addChat(_sessionId: string): IChat { + throw new Error('Multiple chats per session is not supported for agent host sessions'); + } + + async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise { + throw new Error('Multiple chats per session is not supported for agent host sessions'); + } + async sendAndCreateChat(chatId: string, options: ISendRequestOptions): Promise { + const connection = this.connection; + if (!connection) { + throw new Error(this._notConnectedSendErrorMessage()); + } + const session = this._currentNewSession; if (!session || session.sessionId !== chatId) { throw new Error(`Session '${chatId}' not found or not a new session`); @@ -694,7 +657,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent await this._chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); const chatWidget = await this._chatWidgetService.openSession(session.resource, ChatViewPaneTarget); if (!chatWidget) { - throw new Error('[LocalAgentHost] Failed to open chat widget'); + throw new Error(`[${this.id}] Failed to open chat widget`); } // Load session model and apply selected model @@ -709,12 +672,16 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent modelRef.dispose(); } + // Capture existing session keys before sending so we can detect the new + // backend session. Must be captured before sendRequest because the + // backend session may be created during the send and arrive via + // notification before sendRequest resolves. this._ensureSessionCache(); const existingKeys = new Set(this._sessionCache.keys()); const result = await this._chatService.sendRequest(session.resource, query, sendOptions); if (result.kind === 'rejected') { - throw new Error(`[LocalAgentHost] sendRequest rejected: ${result.reason}`); + throw new Error(`[${this.id}] sendRequest rejected: ${result.reason}`); } this._currentNewSessionStatus?.set(SessionStatus.InProgress, undefined); @@ -739,7 +706,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent return committedSession; } } catch { - // Timeout — clean up + // Connection lost or timeout — clean up } finally { this._pendingSession = undefined; } @@ -751,21 +718,25 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent return newSession; } - addChat(_sessionId: string): IChat { - throw new Error('Multiple chats per session is not supported for agent host sessions'); + /** Localized error message when sendAndCreateChat is invoked without a connection. Subclasses can override. */ + protected _notConnectedSendErrorMessage(): string { + return localize('notConnectedSend', "Cannot send request: not connected to agent host."); } - async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise { - throw new Error('Multiple chats per session is not supported for agent host sessions'); - } + // -- Session config plumbing --------------------------------------------- - private async _resolveSessionConfig(sessionId: string, agentProvider: string, workspaceUri: URI, config: Record | undefined): Promise { + private async _resolveSessionConfig(sessionId: string, agentProvider: string, workingDirectory: URI, config: Record | undefined): Promise { + const connection = this.connection; + if (!connection) { + this._setNewSessionLoading(sessionId, false); + return; + } const request = (this._newSessionConfigRequests.get(sessionId) ?? 0) + 1; this._newSessionConfigRequests.set(sessionId, request); try { - const result = await this._agentHostService.resolveSessionConfig({ + const result = await connection.resolveSessionConfig({ provider: agentProvider, - workingDirectory: workspaceUri, + workingDirectory, config, }); if (this._newSessionConfigRequests.get(sessionId) !== request) { @@ -783,7 +754,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent this._onDidChangeSessionConfig.fire(sessionId); } - private _clearNewSessionConfig(sessionId: string): void { + protected _clearNewSessionConfig(sessionId: string): void { this._newSessionWorkspaces.delete(sessionId); this._newSessionConfigs.delete(sessionId); this._newSessionAgentProviders.delete(sessionId); @@ -800,7 +771,6 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent if (!config) { return; } - // Filter schema to only include session-mutable properties const mutableProperties: IResolveSessionConfigResult['schema']['properties'] = {}; const mutableValues: Record = {}; for (const [key, propSchema] of Object.entries(config.schema.properties)) { @@ -819,241 +789,27 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent } } - private _agentProviderFromSessionType(sessionType: string): string { - return sessionType.startsWith('agent-host-') ? sessionType.substring('agent-host-'.length) : DEFAULT_AGENT_PROVIDER; + private _setNewSessionLoading(sessionId: string, loading: boolean): void { + if (this._currentNewSession?.sessionId === sessionId) { + this._currentNewSessionLoading?.set(loading, undefined); + } + } + + protected _rawIdFromChatId(chatId: string): string | undefined { + const prefix = `${this.id}:`; + const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId; + try { + return URI.parse(resourceStr).path.substring(1) || undefined; + } catch { + return undefined; + } } private _getAgentProviderForSession(sessionId: string): string { return this._newSessionAgentProviders.get(sessionId) ?? DEFAULT_AGENT_PROVIDER; } - // -- Private: Session Cache -- - - private _ensureSessionCache(): void { - if (this._cacheInitialized) { - return; - } - this._cacheInitialized = true; - this._refreshSessions(); - } - - private async _refreshSessions(): Promise { - try { - const sessions = await this._agentHostService.listSessions(); - const currentKeys = new Set(); - const added: ISession[] = []; - const changed: ISession[] = []; - - for (const meta of sessions) { - const rawId = AgentSession.id(meta.session); - currentKeys.add(rawId); - - const existing = this._sessionCache.get(rawId); - if (existing) { - if (existing.update(meta)) { - changed.push(existing); - } - } else { - const sessionType = this._sessionTypeForMetadata(meta); - const cached = new LocalSessionAdapter(meta, this.id, sessionType, sessionType, this._agentHostService.authenticationPending); - this._sessionCache.set(rawId, cached); - added.push(cached); - } - } - - const removed: ISession[] = []; - for (const [key, cached] of this._sessionCache) { - if (!currentKeys.has(key)) { - this._sessionCache.delete(key); - this._runningSessionConfigs.delete(cached.sessionId); - removed.push(cached); - } - } - - if (added.length > 0 || removed.length > 0 || changed.length > 0) { - this._onDidChangeSessions.fire({ added, removed, changed }); - } - } catch { - // Agent host may not be ready yet - } - } - - private async _waitForNewSession(existingKeys: Set): Promise { - await this._refreshSessions(); - for (const [key, cached] of this._sessionCache) { - if (!existingKeys.has(key)) { - return cached; - } - } - - const waitDisposables = new DisposableStore(); - try { - const sessionPromise = new Promise((resolve) => { - waitDisposables.add(this._onDidChangeSessions.event(e => { - const newSession = e.added.find(s => { - const rawId = s.resource.path.substring(1); - return !existingKeys.has(rawId); - }); - if (newSession) { - resolve(newSession); - } - })); - }); - return await raceTimeout(sessionPromise, 30_000); - } finally { - waitDisposables.dispose(); - } - } - - private _handleSessionAdded(summary: ISessionSummary): void { - const sessionUri = URI.parse(summary.resource); - const rawId = AgentSession.id(sessionUri); - if (this._sessionCache.has(rawId)) { - return; - } - - const workingDir = typeof summary.workingDirectory === 'string' - ? URI.parse(summary.workingDirectory) - : undefined; - const meta: IAgentSessionMetadata = { - session: sessionUri, - startTime: summary.createdAt, - modifiedTime: summary.modifiedAt, - summary: summary.title, - ...(summary.project ? { project: { uri: URI.parse(summary.project.uri), displayName: summary.project.displayName } } : {}), - model: summary.model, - workingDirectory: workingDir, - isRead: summary.isRead, - isDone: summary.isDone, - }; - const sessionType = this._sessionTypeForMetadata(meta); - const cached = new LocalSessionAdapter(meta, this.id, sessionType, sessionType, this._agentHostService.authenticationPending); - this._sessionCache.set(rawId, cached); - this._onDidChangeSessions.fire({ added: [cached], removed: [], changed: [] }); - } - - private _sessionTypeForMetadata(meta: IAgentSessionMetadata): string { - const provider = AgentSession.provider(meta.session) ?? DEFAULT_AGENT_PROVIDER; - return sessionTypeForProvider(provider); - } - - private _handleSessionRemoved(session: URI | string): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - this._sessionCache.delete(rawId); - this._runningSessionConfigs.delete(cached.sessionId); - this._sessionStateSubscriptions.deleteAndDispose(cached.sessionId); - this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] }); - } - } - - private _handleTitleChanged(session: string, title: string): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - cached.title.set(title, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); - } - } - - private _handleModelChanged(session: string, model: IModelSelection): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - cached.modelSelection = model; - } - const modelId = cached ? `${cached.sessionType}:${model.id}` : undefined; - if (cached && cached.modelId.get() !== modelId) { - cached.modelId.set(modelId, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); - } - } - - private _handleIsReadChanged(session: string, isRead: boolean): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - cached.isRead.set(isRead, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); - } - } - - private _handleIsDoneChanged(session: string, isDone: boolean): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - cached.isArchived.set(isDone, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); - } - } - - private _handleDiffsChanged(session: string, diffs: IFileEdit[]): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - cached.changes.set(diffsToChanges(diffs), undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); - } - } - - private _handleSessionSummaryChanged(session: string, changes: Partial): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (!cached) { - return; - } - - let didChange = false; - - if (changes.status !== undefined) { - const uiStatus = mapProtocolStatus(changes.status); - if (uiStatus !== cached.status.get()) { - cached.status.set(uiStatus, undefined); - didChange = true; - } - } - - if (changes.title !== undefined && changes.title !== cached.title.get()) { - cached.title.set(changes.title, undefined); - didChange = true; - } - - if (changes.diffs !== undefined) { - if (!diffsEqual(cached.changes.get(), changes.diffs)) { - cached.changes.set(diffsToChanges(changes.diffs), undefined); - didChange = true; - } - } - - if (didChange) { - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); - } - } - - private _handleConfigChanged(session: string, config: Record): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (!cached) { - return; - } - const sessionId = cached.sessionId; - const existing = this._runningSessionConfigs.get(sessionId); - if (existing) { - this._runningSessionConfigs.set(sessionId, { - ...existing, - values: { ...existing.values, ...config }, - }); - } else { - // Session was restored (e.g. after reload) — create a minimal - // config entry from the changed values so the picker can render. - this._runningSessionConfigs.set(sessionId, { - schema: { type: 'object', properties: buildMutableConfigSchema(config) }, - values: config, - }); - } - this._onDidChangeSessionConfig.fire(sessionId); - } + // -- Lazy session-state subscription seeding ----------------------------- /** * Lazily acquire a session-state subscription for `sessionId` so that @@ -1068,6 +824,10 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent if (this._sessionStateSubscriptions.has(sessionId)) { return; } + const connection = this.connection; + if (!connection) { + return; + } const rawId = this._rawIdFromChatId(sessionId); if (!rawId) { return; @@ -1077,13 +837,12 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent return; } const sessionUri = AgentSession.uri(cached.agentProvider, rawId); - const ref = this._agentHostService.getSubscription(StateComponents.Session, sessionUri); + const ref = connection.getSubscription(StateComponents.Session, sessionUri); const store = new DisposableStore(); store.add(ref); store.add(ref.object.onDidChange(state => this._seedRunningConfigFromState(sessionId, state))); this._sessionStateSubscriptions.set(sessionId, store); - // Seed from the current snapshot if it has already arrived. const value = ref.object.value; if (value && !(value instanceof Error)) { this._seedRunningConfigFromState(sessionId, value); @@ -1127,38 +886,273 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent this._onDidChangeSessionConfig.fire(sessionId); } - private _setNewSessionLoading(sessionId: string, loading: boolean): void { - if (this._currentNewSession?.sessionId === sessionId) { - this._currentNewSessionLoading?.set(loading, undefined); + // -- Session cache management -------------------------------------------- + + protected _ensureSessionCache(): void { + if (this._cacheInitialized) { + return; } + this._cacheInitialized = true; + this._refreshSessions(); } - private _rawIdFromChatId(chatId: string): string | undefined { - const prefix = `${this.id}:`; - const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId; - try { - return URI.parse(resourceStr).path.substring(1) || undefined; - } catch { - return undefined; + protected async _refreshSessions(): Promise { + const connection = this.connection; + if (!connection) { + return; } - } - - // -- Private: Browse -- - - private async _browseForFolder(): Promise { try { - const selected = await this._fileDialogService.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - title: localize('selectLocalFolder', "Select Folder"), - }); - if (selected?.[0]) { - return this.resolveWorkspace(selected[0]); + const sessions = await connection.listSessions(); + const currentKeys = new Set(); + const added: ISession[] = []; + const changed: ISession[] = []; + + for (const meta of sessions) { + const rawId = AgentSession.id(meta.session); + currentKeys.add(rawId); + + const existing = this._sessionCache.get(rawId); + if (existing) { + if (existing.update(meta)) { + changed.push(existing); + } + } else { + const cached = this.createAdapter(meta); + this._sessionCache.set(rawId, cached); + added.push(cached); + } + } + + const removed: ISession[] = []; + for (const [key, cached] of this._sessionCache) { + if (!currentKeys.has(key)) { + this._sessionCache.delete(key); + this._runningSessionConfigs.delete(cached.sessionId); + removed.push(cached); + } + } + + if (added.length > 0 || removed.length > 0 || changed.length > 0) { + this._onDidChangeSessions.fire({ added, removed, changed }); } } catch { - // dialog was cancelled or failed + // Connection may not be ready yet } - return undefined; } + + private async _waitForNewSession(existingKeys: Set): Promise { + await this._refreshSessions(); + for (const [key, cached] of this._sessionCache) { + if (!existingKeys.has(key)) { + return cached; + } + } + + const waitDisposables = new DisposableStore(); + try { + const sessionPromise = new Promise((resolve) => { + waitDisposables.add(this._onDidChangeSessions.event(e => { + const newSession = e.added.find(s => { + const rawId = s.resource.path.substring(1); + return !existingKeys.has(rawId); + }); + if (newSession) { + resolve(newSession); + } + })); + waitDisposables.add(this.onConnectionLost(() => resolve(undefined))); + }); + return await raceTimeout(sessionPromise, 30_000); + } finally { + waitDisposables.dispose(); + } + } + + // -- AHP notification / action handlers ---------------------------------- + + /** + * Wire AHP notification and action listeners on the given connection. + * Subclasses call this from their constructor (local) or `setConnection` + * (remote), passing a store that bounds the listeners' lifetime. + */ + protected _attachConnectionListeners(connection: IAgentConnection, store: DisposableStore): void { + store.add(connection.onDidNotification(n => { + if (n.type === NotificationType.SessionAdded) { + this._handleSessionAdded(n.summary); + } else if (n.type === NotificationType.SessionRemoved) { + this._handleSessionRemoved(n.session); + } else if (n.type === NotificationType.SessionSummaryChanged) { + this._handleSessionSummaryChanged(n.session, n.changes); + } + })); + + store.add(connection.onDidAction(e => { + if (e.action.type === ActionType.SessionTurnComplete && isSessionAction(e.action)) { + this._refreshSessions(); + } else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) { + this._handleTitleChanged(e.action.session, e.action.title); + } else if (e.action.type === ActionType.SessionModelChanged && isSessionAction(e.action)) { + this._handleModelChanged(e.action.session, e.action.model); + } else if (e.action.type === ActionType.SessionIsReadChanged && isSessionAction(e.action)) { + this._handleIsReadChanged(e.action.session, e.action.isRead); + } else if (e.action.type === ActionType.SessionIsDoneChanged && isSessionAction(e.action)) { + this._handleIsDoneChanged(e.action.session, e.action.isDone); + } else if (e.action.type === ActionType.SessionConfigChanged && isSessionAction(e.action)) { + this._handleConfigChanged(e.action.session, e.action.config); + } else if (e.action.type === ActionType.SessionDiffsChanged && isSessionAction(e.action)) { + this._handleDiffsChanged(e.action.session, e.action.diffs); + } + })); + } + + private _handleSessionAdded(summary: ISessionSummary): void { + const sessionUri = URI.parse(summary.resource); + const rawId = AgentSession.id(sessionUri); + if (this._sessionCache.has(rawId)) { + return; + } + + const workingDir = typeof summary.workingDirectory === 'string' + ? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectory)) + : undefined; + const meta: IAgentSessionMetadata = { + session: sessionUri, + startTime: summary.createdAt, + modifiedTime: summary.modifiedAt, + summary: summary.title, + ...(summary.project ? { project: { uri: this.mapProjectUri(URI.parse(summary.project.uri)), displayName: summary.project.displayName } } : {}), + model: summary.model, + workingDirectory: workingDir, + isRead: summary.isRead, + isDone: summary.isDone, + }; + const cached = this.createAdapter(meta); + this._sessionCache.set(rawId, cached); + this._onDidChangeSessions.fire({ added: [cached], removed: [], changed: [] }); + } + + private _handleSessionRemoved(session: URI | string): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + this._sessionCache.delete(rawId); + this._runningSessionConfigs.delete(cached.sessionId); + this._sessionStateSubscriptions.deleteAndDispose(cached.sessionId); + this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] }); + } + } + + private _handleTitleChanged(session: string, title: string): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.title.set(title, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + } + + private _handleModelChanged(session: string, model: IModelSelection): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.modelSelection = model; + } + const modelId = cached ? `${cached.resource.scheme}:${model.id}` : undefined; + if (cached && cached.modelId.get() !== modelId) { + cached.modelId.set(modelId, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + } + + private _handleIsReadChanged(session: string, isRead: boolean): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.isRead.set(isRead, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + } + + private _handleIsDoneChanged(session: string, isDone: boolean): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.isArchived.set(isDone, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + } + + private _handleDiffsChanged(session: string, diffs: IFileEdit[]): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.changes.set(diffsToChanges(diffs, this._diffUriMapper()), undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + } + + private _handleSessionSummaryChanged(session: string, changes: Partial): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (!cached) { + return; + } + + let didChange = false; + + if (changes.status !== undefined) { + const uiStatus = mapProtocolStatus(changes.status); + if (uiStatus !== cached.status.get()) { + cached.status.set(uiStatus, undefined); + didChange = true; + } + } + + if (changes.title !== undefined && changes.title !== cached.title.get()) { + cached.title.set(changes.title, undefined); + didChange = true; + } + + if (changes.diffs !== undefined) { + const mapUri = this._diffUriMapper(); + if (!diffsEqual(cached.changes.get(), changes.diffs, mapUri)) { + cached.changes.set(diffsToChanges(changes.diffs, mapUri), undefined); + didChange = true; + } + } + + if (didChange) { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + } + + private _handleConfigChanged(session: string, config: Record): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (!cached) { + return; + } + const sessionId = cached.sessionId; + const existing = this._runningSessionConfigs.get(sessionId); + if (existing) { + this._runningSessionConfigs.set(sessionId, { + ...existing, + values: { ...existing.values, ...config }, + }); + } else { + // Session was restored (e.g. after reload) — create a minimal + // config entry from the changed values so the picker can render. + this._runningSessionConfigs.set(sessionId, { + schema: { type: 'object', properties: buildMutableConfigSchema(config) }, + values: config, + }); + } + this._onDidChangeSessionConfig.fire(sessionId); + } + + /** + * Optional URI mapper used when applying diff changes. Subclasses + * override to translate remote diff URIs into agent-host URIs. + */ + protected _diffUriMapper(): ((uri: URI) => URI) | undefined { return undefined; } } diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHost.contribution.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHost.contribution.ts similarity index 100% rename from src/vs/sessions/contrib/localAgentHost/browser/localAgentHost.contribution.ts rename to src/vs/sessions/contrib/agentHost/browser/localAgentHost.contribution.ts diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts new file mode 100644 index 00000000000..df787136ed0 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts @@ -0,0 +1,186 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { IObservable } from '../../../../base/common/observable.js'; +import { basename } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { AgentSession, IAgentConnection, IAgentHostService, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; +import type { IRootState } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js'; +import { buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; +import { ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; + +const LOCAL_PROVIDER_ID = 'local-agent-host'; + +/** + * Derives the session type / URI scheme from an agent provider name. + * Must match the type string registered by AgentHostContribution + * (`agent-host-${agent.provider}`). + */ +function sessionTypeForProvider(provider: string): string { + return `agent-host-${provider}`; +} + +/** + * Local-window sessions provider backed by the in-process + * {@link IAgentHostService}. A thin subclass of + * {@link BaseAgentHostSessionsProvider} that supplies the local-only + * variation: a built-in connection that is always present, session-type + * synchronization from the local agent host's `rootState`, a + * contributions-based session-type fallback for the pre-hydration window, + * and a local file-picker browse action. + */ +export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvider { + + readonly id = LOCAL_PROVIDER_ID; + readonly label: string; + readonly icon: ThemeIcon = Codicon.vm; + readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; + + private readonly _localLabel = localize('localAgentHostSessionTypeLocation', "Local"); + private readonly _localDescription = new MarkdownString(this._localLabel); + private _hasRootStateSnapshot = false; + + override get sessionTypes(): readonly ISessionType[] { + const rootStateValue = this._agentHostService.rootState.value; + return this._hasRootStateSnapshot || rootStateValue !== undefined ? this._sessionTypes : this._getSessionTypesFromContributions(); + } + + constructor( + @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IFileDialogService private readonly _fileDialogService: IFileDialogService, + @IChatSessionsService chatSessionsService: IChatSessionsService, + @IChatService chatService: IChatService, + @IChatWidgetService chatWidgetService: IChatWidgetService, + @ILanguageModelsService languageModelsService: ILanguageModelsService, + ) { + super(chatSessionsService, chatService, chatWidgetService, languageModelsService); + + this.label = localize('localAgentHostLabel', "Local Agent Host"); + + this.browseActions = [{ + label: localize('folders', "Folders"), + icon: Codicon.folderOpened, + providerId: this.id, + run: () => this._browseForFolder(), + }]; + + this._attachConnectionListeners(this._agentHostService, this._store); + + const rootStateValue = this._agentHostService.rootState.value; + if (rootStateValue !== undefined) { + this._hasRootStateSnapshot = true; + } + if (rootStateValue && !(rootStateValue instanceof Error)) { + this._syncSessionTypesFromRootState(rootStateValue); + } + this._register(this._agentHostService.rootState.onDidChange(rootState => { + const didHydrate = !this._hasRootStateSnapshot; + this._hasRootStateSnapshot = true; + this._syncSessionTypesFromRootState(rootState, didHydrate); + })); + } + + // -- BaseAgentHostSessionsProvider hooks --------------------------------- + + protected get connection(): IAgentConnection { return this._agentHostService; } + + protected get authenticationPending(): IObservable { return this._agentHostService.authenticationPending; } + + protected createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter { + const agentProvider = AgentSession.provider(meta.session) ?? 'copilot'; + const sessionType = sessionTypeForProvider(agentProvider); + return new AgentHostSessionAdapter(meta, this.id, sessionType, sessionType, { + icon: this.icon, + description: this._localDescription, + loading: this._agentHostService.authenticationPending, + buildWorkspace: (project, workingDirectory) => LocalAgentHostSessionsProvider.buildWorkspace(project, workingDirectory), + }); + } + + protected resourceSchemeForSessionType(sessionTypeId: string): string { + return sessionTypeId; + } + + protected agentProviderFromSessionType(sessionType: string): string { + const prefix = 'agent-host-'; + return sessionType.startsWith(prefix) ? sessionType.substring(prefix.length) : sessionType; + } + + // -- Session type sync from root state ----------------------------------- + + private _syncSessionTypesFromRootState(rootState: IRootState, forceFire = false): void { + const next = rootState.agents.map((agent): ISessionType => ({ + id: sessionTypeForProvider(agent.provider), + label: this._formatSessionTypeLabel(agent.displayName || agent.provider), + icon: Codicon.vm, + })); + + const prev = this._sessionTypes; + if (!forceFire && prev.length === next.length && prev.every((t, i) => t.id === next[i].id && t.label === next[i].label)) { + return; + } + this._sessionTypes = next; + this._onDidChangeSessionTypes.fire(); + } + + private _formatSessionTypeLabel(agentLabel: string): string { + return localize('localAgentHostSessionType', "{0} [{1}]", agentLabel, this._localLabel); + } + + private _getSessionTypesFromContributions(): ISessionType[] { + return this._chatSessionsService.getAllChatSessionContributions() + .filter(contribution => contribution.type.startsWith('agent-host-')) + .map((contribution): ISessionType => ({ + id: contribution.type, + label: this._formatSessionTypeLabel(contribution.displayName), + icon: Codicon.vm, + })); + } + + // -- Workspaces ---------------------------------------------------------- + + static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined): ISessionWorkspace | undefined { + return buildAgentHostSessionWorkspace(project, workingDirectory, { fallbackIcon: Codicon.folder, requiresWorkspaceTrust: true }); + } + + resolveWorkspace(repositoryUri: URI): ISessionWorkspace { + const folderName = basename(repositoryUri) || repositoryUri.path; + return { + label: folderName, + icon: Codicon.folder, + repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: true, + }; + } + + // -- Browse -------------------------------------------------------------- + + private async _browseForFolder(): Promise { + try { + const selected = await this._fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectLocalFolder', "Select Folder"), + }); + if (selected?.[0]) { + return this.resolveWorkspace(selected[0]); + } + } catch { + // dialog was cancelled or failed + } + return undefined; + } +} diff --git a/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts similarity index 100% rename from src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts rename to src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 89716bf057e..17786c2c4c9 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -3,41 +3,29 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { raceTimeout } from '../../../../base/common/async.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { basename } from '../../../../base/common/resources.js'; -import { constObservable, derived, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { agentHostUri } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; import { AGENT_HOST_SCHEME, agentHostAuthority, toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; -import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; -import type { IResolveSessionConfigResult, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js'; -import type { IFileEdit, IModelSelection, IRootState, ISessionConfigPropertySchema, ISessionState, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; -import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; -import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; -import { IChatSendRequestOptions, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { agentHostSessionWorkspaceKey, buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; -import { isSessionConfigComplete } from '../../../common/sessionConfig.js'; -import { diffsToChanges, diffsEqual, mapProtocolStatus } from '../../../common/agentHostDiffs.js'; -import { ISessionChangeEvent, ISendRequestOptions } from '../../../services/sessions/common/sessionsProvider.js'; -import { IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../common/agentHostSessionsProvider.js'; -import { ISession, IChat, IGitHubInfo, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, ISessionType, COPILOT_CLI_SESSION_TYPE } from '../../../services/sessions/common/session.js'; +import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; +import { buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; +import { COPILOT_CLI_SESSION_TYPE, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; import { remoteAgentHostSessionTypeId } from '../common/remoteAgentHostSessionType.js'; /** The default agent provider name used by agent hosts when no explicit provider is specified. */ @@ -52,19 +40,10 @@ const WELL_KNOWN_AGENT_SESSION_TYPES: ReadonlyMap = new Map([ [DEFAULT_AGENT_HOST_PROVIDER, COPILOT_CLI_SESSION_TYPE], ]); -/** - * Look up the well-known local session type for an agent host provider name. - * Returns the mapped type (e.g. `copilotcli`) or `undefined` for unknown providers. - */ function wellKnownSessionType(agentProvider: string): string | undefined { return WELL_KNOWN_AGENT_SESSION_TYPES.get(agentProvider); } -/** - * Reverse lookup: given a local session type, find the well-known agent host - * provider name. Returns `undefined` when the session type is a per-connection - * ID rather than a well-known mapping. - */ function wellKnownAgentProvider(sessionType: string): string | undefined { for (const [provider, type] of WELL_KNOWN_AGENT_SESSION_TYPES) { if (type === sessionType) { @@ -74,79 +53,10 @@ function wellKnownAgentProvider(sessionType: string): string | undefined { return undefined; } -/** Known auto-approve config values. */ -const AUTO_APPROVE_ENUM = ['default', 'autoApprove', 'autopilot']; - -/** - * Builds a minimal session-mutable config schema from changed values. - * Used when a restored session receives a ConfigChanged action before - * the full schema has been hydrated. - */ -function buildMutableConfigSchema(config: Record): Record { - const properties: Record = {}; - for (const key of Object.keys(config)) { - properties[key] = { - type: 'string', - title: key, - sessionMutable: true, - enum: key === 'autoApprove' ? AUTO_APPROVE_ENUM : [config[key]], - }; - } - return properties; -} - function toLocalProjectUri(uri: URI, connectionAuthority: string): URI { return uri.scheme === Schemas.file ? toAgentHostUri(uri, connectionAuthority) : uri; } -function toLocalDiffUri(connectionAuthority: string): (uri: URI) => URI { - return uri => toAgentHostUri(uri, connectionAuthority); -} - -interface IChatData { - /** Globally unique session ID (`providerId:localId`). */ - readonly id: string; - /** Resource URI identifying this session. */ - readonly resource: URI; - /** ID of the provider that owns this session. */ - readonly providerId: string; - /** Session type ID (e.g., 'copilot-cli', 'copilot-cloud'). */ - readonly sessionType: string; - /** Icon for this session. */ - readonly icon: ThemeIcon; - /** When the session was created. */ - readonly createdAt: Date; - /** Workspace this session operates on. */ - readonly workspace: IObservable; - - // Reactive properties - - /** Session display title (changes when auto-titled or renamed). */ - readonly title: IObservable; - /** When the session was last updated. */ - readonly updatedAt: IObservable; - /** Current session status. */ - readonly status: IObservable; - /** File changes produced by the session. */ - readonly changes: IObservable; - /** Currently selected model identifier. */ - readonly modelId: IObservable; - /** Currently selected mode identifier and kind. */ - readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; - /** Whether the session is still initializing (e.g., resolving git repository). */ - readonly loading: ISettableObservable; - /** Whether the session is archived. */ - readonly isArchived: IObservable; - /** Whether the session has been read. */ - readonly isRead: IObservable; - /** Status description shown while the session is active (e.g., current agent action). */ - readonly description: IObservable; - /** Timestamp of when the last agent turn ended, if any. */ - readonly lastTurnEnd: IObservable; - /** GitHub information associated with this session, if any. */ - readonly gitHubInfo: IObservable; -} - export interface IRemoteAgentHostSessionsProviderConfig { readonly address: string; readonly name: string; @@ -155,138 +65,35 @@ export interface IRemoteAgentHostSessionsProviderConfig { } /** - * Adapts agent host session metadata into the {@link IChatData} facade. - */ -class RemoteSessionAdapter implements IChatData { - - readonly id: string; - readonly resource: URI; - readonly providerId: string; - readonly sessionType: string; - readonly icon = Codicon.remote; - readonly createdAt: Date; - readonly workspace: ISettableObservable; - readonly title: ISettableObservable; - readonly updatedAt: ISettableObservable; - readonly status: ISettableObservable; - readonly changes = observableValue('changes', []); - readonly modelId: ISettableObservable; - modelSelection: IModelSelection | undefined; - readonly mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', undefined); - readonly loading = observableValue('loading', false); - readonly isArchived = observableValue('isArchived', false); - readonly isRead = observableValue('isRead', true); - readonly description: ISettableObservable; - readonly lastTurnEnd: ISettableObservable; - readonly gitHubInfo = observableValue('gitHubInfo', undefined); - - /** The agent provider name (e.g. 'copilot') for constructing backend URIs. */ - readonly agentProvider: string; - - constructor( - metadata: IAgentSessionMetadata, - providerId: string, - resourceScheme: string, - logicalSessionType: string, - private readonly _providerLabel: string, - private readonly _mapUri: (uri: URI) => URI, - ) { - const rawId = AgentSession.id(metadata.session); - this.agentProvider = AgentSession.provider(metadata.session) ?? DEFAULT_AGENT_HOST_PROVIDER; - this.resource = URI.from({ scheme: resourceScheme, path: `/${rawId}` }); - this.id = `${providerId}:${this.resource.toString()}`; - this.providerId = providerId; - this.sessionType = logicalSessionType; - this.createdAt = new Date(metadata.startTime); - this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`); - this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); - this.modelSelection = metadata.model; - this.status = observableValue('status', metadata.status !== undefined ? mapProtocolStatus(metadata.status) : SessionStatus.Completed); - this.modelId = observableValue('modelId', metadata.model ? `${resourceScheme}:${metadata.model.id}` : undefined); - this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); - this.description = observableValue('description', new MarkdownString().appendText(this._providerLabel)); - this.workspace = observableValue('workspace', RemoteAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory, this._providerLabel)); - - if (metadata.isRead === false) { - this.isRead.set(false, undefined); - } - if (metadata.isDone) { - this.isArchived.set(true, undefined); - } - if (metadata.diffs && metadata.diffs.length > 0) { - this.changes.set(diffsToChanges(metadata.diffs, this._mapUri), undefined); - } - } - - update(metadata: IAgentSessionMetadata): void { - this.title.set(metadata.summary || this.title.get(), undefined); - this.updatedAt.set(new Date(metadata.modifiedTime), undefined); - this.lastTurnEnd.set(metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined, undefined); - if (metadata.status !== undefined) { - const uiStatus = mapProtocolStatus(metadata.status); - if (uiStatus !== this.status.get()) { - this.status.set(uiStatus, undefined); - } - } - if (metadata.isRead !== undefined) { - this.isRead.set(metadata.isRead, undefined); - } - if (metadata.isDone !== undefined) { - this.isArchived.set(metadata.isDone, undefined); - } - this.modelSelection = metadata.model; - this.modelId.set(metadata.model ? `${this.resource.scheme}:${metadata.model.id}` : undefined, undefined); - const workspace = RemoteAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory, this._providerLabel); - if (agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get())) { - this.workspace.set(workspace, undefined); - } - if (metadata.diffs && !diffsEqual(this.changes.get(), metadata.diffs, this._mapUri)) { - this.changes.set(diffsToChanges(metadata.diffs, this._mapUri), undefined); - } - } -} - -/** - * Sessions provider for a remote agent host connection. - * One instance is created per connection and handles all agents on it. - * - * Fully implements {@link ISessionsProvider}: - * - Session listing via {@link IAgentConnection.listSessions} with incremental updates - * - Session creation and initial request sending via {@link IChatService} - * - Session actions (delete, rename, etc.) where supported by the protocol + * Sessions provider for a remote agent host connection. A thin subclass of + * {@link BaseAgentHostSessionsProvider} that adds the connection-lifecycle + * surface (`setConnection`/`clearConnection`), sticky authentication-pending + * tracking, the well-known session-type mapping, and a remote folder picker. * * **URI/ID scheme:** * - **rawId** - unique session identifier (e.g. `abc123`), used as the cache key. - * - **resource** - `{resourceScheme}:///{rawId}` (e.g. `remote-host__4321-copilot:///abc123`). - * The scheme is the unique per-connection id and routes the chat service to the - * correct {@link AgentHostSessionHandler}. - * - **sessionType** - the logical session type (e.g. `copilotcli` for copilot agents, - * or the per-connection id for other agents). Distinct from the resource scheme. + * - **resource** - `{resourceScheme}:///{rawId}`. The scheme is the unique + * per-connection id and routes the chat service to the correct + * {@link AgentHostSessionHandler}. + * - **sessionType** - the logical session type (e.g. `copilotcli` for copilot + * agents, or the per-connection id for other agents). Distinct from the + * resource scheme. * - **sessionId** - `{providerId}:{resource}` - the provider-scoped ID used by - * {@link ISessionsProvider} methods. The rawId can be extracted from the resource path. - * - Protocol operations (e.g. `disposeSession`) use the canonical agent session URI - * (`copilot:///abc123`), reconstructed via {@link AgentSession.uri}. + * {@link ISessionsProvider} methods. + * - Protocol operations (e.g. `disposeSession`) use the canonical agent + * session URI (`copilot:///abc123`), reconstructed via {@link AgentSession.uri}. */ -export class RemoteAgentHostSessionsProvider extends Disposable implements IAgentHostSessionsProvider { +export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvider { readonly id: string; readonly label: string; readonly icon: ThemeIcon = Codicon.remote; readonly remoteAddress: string; + readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; + private _outputChannelId: string | undefined; get outputChannelId(): string | undefined { return this._outputChannelId; } - /** - * Session types for this provider, one per agent discovered on the host. - * Populated dynamically from the connection's root state and updated when - * agents appear or disappear. Each entry's id is the logical session type - * (e.g. `copilotcli` for copilot agents, the unique per-connection ID for - * other agents). The resource URI scheme and language model vendor remain - * the unique per-connection ID produced by {@link remoteAgentHostSessionTypeId}. - */ - private _sessionTypes: ISessionType[] = []; - get sessionTypes(): readonly ISessionType[] { return this._sessionTypes; } - /** * Maps logical session type id → unique per-connection resource scheme. * Copilot agents map to `COPILOT_CLI_SESSION_TYPE` as the logical type @@ -294,98 +101,36 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen */ private readonly _sessionTypeToResourceScheme = new Map(); - private readonly _onDidChangeSessionTypes = this._register(new Emitter()); - readonly onDidChangeSessionTypes: Event = this._onDidChangeSessionTypes.event; - private readonly _connectionStatus = observableValue('connectionStatus', RemoteAgentHostConnectionStatus.Disconnected); readonly connectionStatus: IObservable = this._connectionStatus; /** * `true` while we are still resolving and pushing tokens for the host's * `protectedResources`. Defaults to `true` so that sessions surface as - * loading until the first authentication pass settles. Toggled by - * {@link RemoteAgentHostContribution} around each per-connection auth pass. + * loading until the first authentication pass settles. */ private readonly _authenticationPending = observableValue('authenticationPending', true); - readonly authenticationPending: IObservable = this._authenticationPending; private _authenticationSettled = false; - setAuthenticationPending(pending: boolean): void { - // Sticky: once the first authentication pass settles, never surface - // pending again. Subsequent re-auths (account/session changes, reconnect) - // happen silently in the background and should not flicker the UI. - if (this._authenticationSettled) { - return; - } - if (!pending) { - this._authenticationSettled = true; - } - this._authenticationPending.set(pending, undefined); - } - - private readonly _onDidChangeSessions = this._register(new Emitter()); - readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; - - private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); - readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; - private readonly _onDidChangeSessionConfig = this._register(new Emitter()); - readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event; - - readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; - - /** Cache of adapted sessions, keyed by raw session ID. */ - private readonly _sessionCache = new Map(); - - /** - * Temporary session that has been sent (first turn dispatched) but not yet - * committed to a real backend session. Shown in the session list until the - * server creates the backend session, at which point it is replaced via - * {@link _onDidReplaceSession}. - */ - private _pendingSession: ISession | undefined; - - /** Selected model for the current new session. */ - private _selectedModelId: string | undefined; - /** Settable status for the current new session, kept to avoid unsafe cast from IObservable. */ - private _currentNewSessionStatus: ISettableObservable | undefined; - /** Settable model for the current new session, kept to avoid unsafe cast from IObservable. */ - private _currentNewSessionModelId: ISettableObservable | undefined; - private readonly _newSessionWorkspaces = new Map(); - private readonly _newSessionConfigs = new Map(); - private readonly _newSessionAgentProviders = new Map(); - private readonly _newSessionConfigRequests = new Map(); - - /** Config for running sessions (session-mutable properties only), keyed by session ID. */ - private readonly _runningSessionConfigs = new Map(); - - /** - * Lazy session-state subscriptions used to seed {@link _runningSessionConfigs} - * for sessions that already exist on the agent host (e.g. created in a prior - * window or after a reconnect). The underlying wire subscription is - * reference-counted by {@link IAgentConnection.getSubscription}, so when - * the session handler is also subscribed (chat content open) this shares - * the existing wire subscription rather than opening a new one. Cleared - * when the connection is replaced or removed. Keyed by session ID. - */ - private readonly _sessionStateSubscriptions = this._register(new DisposableMap()); + private readonly _onDidDisconnect = this._register(new Emitter()); + protected override get onConnectionLost(): Event { return this._onDidDisconnect.event; } private _connection: IAgentConnection | undefined; private _defaultDirectory: string | undefined; private readonly _connectionListeners = this._register(new DisposableStore()); - private readonly _onDidDisconnect = this._register(new Emitter()); private readonly _connectionAuthority: string; private readonly _connectOnDemand: (() => Promise) | undefined; constructor( config: IRemoteAgentHostSessionsProviderConfig, @IFileDialogService private readonly _fileDialogService: IFileDialogService, - @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, - @IChatService private readonly _chatService: IChatService, - @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, - @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @INotificationService private readonly _notificationService: INotificationService, + @IChatSessionsService chatSessionsService: IChatSessionsService, + @IChatService chatService: IChatService, + @IChatWidgetService chatWidgetService: IChatWidgetService, + @ILanguageModelsService languageModelsService: ILanguageModelsService, ) { - super(); + super(chatSessionsService, chatService, chatWidgetService, languageModelsService); this._connectionAuthority = agentHostAuthority(config.address); this._connectOnDemand = config.connectOnDemand; @@ -397,29 +142,88 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen this.browseActions = [{ label: localize('folders', "Folders"), - // label: localize('browseRemote', "Browse Folders ({0})...", displayName), icon: Codicon.remote, providerId: this.id, run: () => this._browseForFolder(), }]; } - /** - * Update the connection status for this provider. - * Called by the contribution when connection state changes. - */ + // -- BaseAgentHostSessionsProvider hooks --------------------------------- + + protected get connection(): IAgentConnection | undefined { return this._connection; } + + protected get authenticationPending(): IObservable { return this._authenticationPending; } + + protected createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter { + const provider = AgentSession.provider(meta.session) ?? DEFAULT_AGENT_HOST_PROVIDER; + const resourceScheme = remoteAgentHostSessionTypeId(this._connectionAuthority, provider); + const logicalType = this._logicalSessionTypeForProvider(provider); + return new AgentHostSessionAdapter(meta, this.id, resourceScheme, logicalType, { + icon: this.icon, + description: new MarkdownString().appendText(this.label), + loading: this._authenticationPending, + buildWorkspace: (project, workingDirectory) => RemoteAgentHostSessionsProvider.buildWorkspace(project, workingDirectory, this.label), + mapDiffUri: uri => toAgentHostUri(uri, this._connectionAuthority), + }); + } + + protected resourceSchemeForSessionType(sessionTypeId: string): string { + return this._sessionTypeToResourceScheme.get(sessionTypeId) ?? sessionTypeId; + } + + protected agentProviderFromSessionType(sessionType: string): string { + return wellKnownAgentProvider(sessionType) ?? sessionType.substring(`remote-${this._connectionAuthority}-`.length); + } + + protected override mapWorkingDirectoryUri(uri: URI): URI { + return toAgentHostUri(uri, this._connectionAuthority); + } + + protected override mapProjectUri(uri: URI): URI { + return toLocalProjectUri(uri, this._connectionAuthority); + } + + protected override _diffUriMapper(): (uri: URI) => URI { + return uri => toAgentHostUri(uri, this._connectionAuthority); + } + + protected override _validateBeforeCreate(_sessionType: ISessionType): void { + if (!this._connection) { + throw new Error(localize('notConnectedSession', "Cannot create session: not connected to remote agent host '{0}'.", this.label)); + } + } + + protected override _noAgentsErrorMessage(): string { + return localize('noAgents', "Remote agent host '{0}' has not advertised any agents yet.", this.label); + } + + protected override _notConnectedSendErrorMessage(): string { + return localize('notConnectedSend', "Cannot send request: not connected to remote agent host '{0}'.", this.label); + } + + // -- Connection lifecycle ------------------------------------------------ + + /** Update the connection status for this provider. */ setConnectionStatus(status: RemoteAgentHostConnectionStatus): void { this._connectionStatus.set(status, undefined); } - /** - * Set the output channel ID for this provider's IPC log. - */ + /** Set the output channel ID for this provider's IPC log. */ setOutputChannelId(id: string): void { this._outputChannelId = id; } - // -- Connection Management -- + setAuthenticationPending(pending: boolean): void { + // Sticky: once the first authentication pass settles, never surface + // pending again. Subsequent re-auths happen silently in the background. + if (this._authenticationSettled) { + return; + } + if (!pending) { + this._authenticationSettled = true; + } + this._authenticationPending.set(pending, undefined); + } /** * Wire a live connection to this provider, enabling session operations and folder browsing. @@ -435,9 +239,6 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen this._defaultDirectory = defaultDirectory; // Dynamically discover session types from the host's advertised agents. - // One `ISessionType` per agent provider, with the type id matching the - // URI scheme used by `registerChatSessionContentProvider` and the - // `targetChatSessionType` published by `AgentHostLanguageModelProvider`. const rootStateValue = connection.rootState.value; if (rootStateValue && !(rootStateValue instanceof Error)) { this._syncSessionTypesFromRootState(rootStateValue); @@ -446,54 +247,61 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen this._syncSessionTypesFromRootState(rootState); })); - this._connectionListeners.add(connection.onDidNotification(n => { - if (n.type === NotificationType.SessionAdded) { - this._handleSessionAdded(n.summary); - } else if (n.type === NotificationType.SessionRemoved) { - this._handleSessionRemoved(n.session); - } else if (n.type === NotificationType.SessionSummaryChanged) { - this._handleSessionSummaryChanged(n.session, n.changes); - } - })); - - // Handle session state changes from the server - this._connectionListeners.add(this._connection.onDidAction(e => { - if (e.action.type === ActionType.SessionTurnComplete && isSessionAction(e.action)) { - const cts = new CancellationTokenSource(); - this._refreshSessions(cts.token).finally(() => cts.dispose()); - } else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) { - this._handleTitleChanged(e.action.session, e.action.title); - } else if (e.action.type === ActionType.SessionModelChanged && isSessionAction(e.action)) { - this._handleModelChanged(e.action.session, e.action.model); - } else if (e.action.type === ActionType.SessionIsReadChanged && isSessionAction(e.action)) { - this._handleIsReadChanged(e.action.session, e.action.isRead); - } else if (e.action.type === ActionType.SessionIsDoneChanged && isSessionAction(e.action)) { - this._handleIsDoneChanged(e.action.session, e.action.isDone); - } else if (e.action.type === ActionType.SessionConfigChanged && isSessionAction(e.action)) { - this._handleConfigChanged(e.action.session, e.action.config); - } else if (e.action.type === ActionType.SessionDiffsChanged && isSessionAction(e.action)) { - this._handleDiffsChanged(e.action.session, e.action.diffs); - } - })); + this._attachConnectionListeners(connection, this._connectionListeners); // Always refresh sessions when a connection is (re)established - const cts = new CancellationTokenSource(); this._cacheInitialized = true; - this._refreshSessions(cts.token).finally(() => cts.dispose()); + this._refreshSessions(); } + /** + * Clear the connection, e.g. when the remote host disconnects. + * Retains the provider registration so it remains visible in the UI. + */ + clearConnection(): void { + this._connectionListeners.clear(); + this._sessionStateSubscriptions.clearAndDisposeAll(); + this._onDidDisconnect.fire(); + this._connection = undefined; + this._defaultDirectory = undefined; + if (this._currentNewSession) { + this._clearNewSessionConfig(this._currentNewSession.sessionId); + this._currentNewSession = undefined; + } + this._currentNewSessionStatus = undefined; + this._currentNewSessionModelId = undefined; + this._currentNewSessionLoading = undefined; + this._selectedModelId = undefined; + + if (this._sessionTypes.length > 0) { + this._sessionTypes = []; + this._sessionTypeToResourceScheme.clear(); + this._onDidChangeSessionTypes.fire(); + } + + const removed: ISession[] = Array.from(this._sessionCache.values()); + if (this._pendingSession) { + removed.push(this._pendingSession); + this._pendingSession = undefined; + } + this._sessionCache.clear(); + this._runningSessionConfigs.clear(); + this._cacheInitialized = false; + if (removed.length > 0) { + this._onDidChangeSessions.fire({ added: [], removed, changed: [] }); + } + } + + // -- Session-type sync --------------------------------------------------- + /** * Reconcile `_sessionTypes` against the agents advertised by the host's * root state. Adds new types, removes types whose agents disappeared, and * fires {@link onDidChangeSessionTypes} if anything actually changed. * - * Each entry's label is formatted as ` []` - * so the session type picker shows both the agent identity and which - * remote host it lives on. When an agent does not advertise a display - * name, fall back to the host label alone rather than emitting a bare - * `()` with an empty prefix. + * Each entry's label is formatted as ` []`. */ - private _syncSessionTypesFromRootState(rootState: IRootState): void { + private _syncSessionTypesFromRootState(rootState: { agents: ReadonlyArray<{ provider: string; displayName?: string }> }): void { const nextMap = new Map(); const next = rootState.agents.map((agent): ISessionType => { const resourceScheme = remoteAgentHostSessionTypeId(this._connectionAuthority, agent.provider); @@ -525,71 +333,15 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen /** * Returns the logical session type for a given agent provider. * Well-known providers (see {@link WELL_KNOWN_AGENT_SESSION_TYPES}) map - * to the corresponding platform session type so that remote sessions - * align with local and cloud sessions of the same kind. - * Other agents keep the unique per-connection ID. + * to the corresponding platform session type. Other agents keep the + * unique per-connection ID. */ private _logicalSessionTypeForProvider(provider: string): string { return wellKnownSessionType(provider) ?? remoteAgentHostSessionTypeId(this._connectionAuthority, provider); } - /** - * Returns the unique per-connection resource scheme for a session metadata entry. - */ - private _resourceSchemeForMetadata(meta: IAgentSessionMetadata): string { - const provider = AgentSession.provider(meta.session) ?? DEFAULT_AGENT_HOST_PROVIDER; - return remoteAgentHostSessionTypeId(this._connectionAuthority, provider); - } + // -- Workspaces ---------------------------------------------------------- - /** - * Returns the logical session type for a session metadata entry. - */ - private _logicalSessionTypeForMetadata(meta: IAgentSessionMetadata): string { - const provider = AgentSession.provider(meta.session) ?? DEFAULT_AGENT_HOST_PROVIDER; - return this._logicalSessionTypeForProvider(provider); - } - - /** - * Clear the connection, e.g. when the remote host disconnects. - * Retains the provider registration so it remains visible in the UI. - */ - clearConnection(): void { - this._connectionListeners.clear(); - this._sessionStateSubscriptions.clearAndDisposeAll(); - this._onDidDisconnect.fire(); - this._connection = undefined; - this._defaultDirectory = undefined; - if (this._currentNewSession) { - this._clearNewSessionConfig(this._currentNewSession.id); - this._currentNewSession = undefined; - } - this._currentNewSessionStatus = undefined; - this._selectedModelId = undefined; - - if (this._sessionTypes.length > 0) { - this._sessionTypes = []; - this._sessionTypeToResourceScheme.clear(); - this._onDidChangeSessionTypes.fire(); - } - - const removed: ISession[] = Array.from(this._sessionCache.values()).map(cached => this._chatToSession(cached)); - if (this._pendingSession) { - removed.push(this._pendingSession); - this._pendingSession = undefined; - } - this._sessionCache.clear(); - this._runningSessionConfigs.clear(); - this._cacheInitialized = false; - if (removed.length > 0) { - this._onDidChangeSessions.fire({ added: [], removed, changed: [] }); - } - } - - // -- Workspaces -- - - /** - * Builds workspace metadata from a working directory path on the remote host. - */ static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, providerLabel: string): ISessionWorkspace | undefined { return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: false }); } @@ -608,779 +360,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen return this._buildWorkspaceFromUri(repositoryUri); } - // -- Sessions -- - - getSessionTypes(repositoryUri: URI): ISessionType[] { - return [...this.sessionTypes]; - } - - getSessions(): ISession[] { - this._ensureSessionCache(); - const sessions: ISession[] = Array.from(this._sessionCache.values()).map(cached => this._chatToSession(cached)); - if (this._pendingSession) { - sessions.push(this._pendingSession); - } - return sessions; - } - - getSessionByResource(resource: URI): ISession | undefined { - if (this._currentNewSession?.resource.toString() === resource.toString()) { - return this._chatToSession(this._currentNewSession); - } - - if (this._pendingSession?.resource.toString() === resource.toString()) { - return this._pendingSession; - } - - this._ensureSessionCache(); - for (const cached of this._sessionCache.values()) { - if (cached.resource.toString() === resource.toString()) { - return this._chatToSession(cached); - } - } - - return undefined; - } - - // -- Session Lifecycle -- - - private _currentNewSession: IChatData | undefined; - - createNewSession(workspaceUri: URI, sessionTypeId: string): ISession { - if (!this._connection) { - throw new Error(localize('notConnectedSession', "Cannot create session: not connected to remote agent host '{0}'.", this.label)); - } - - const sessionType = this.sessionTypes.find(t => t.id === sessionTypeId); - if (!sessionType) { - throw new Error(localize('noAgents', "Remote agent host '{0}' has not advertised any agents yet.", this.label)); - } - - // Reset draft state from any prior unsent session - if (this._currentNewSession) { - this._clearNewSessionConfig(this._currentNewSession.id); - } - this._currentNewSession = undefined; - this._selectedModelId = undefined; - this._currentNewSessionModelId = undefined; - - const sessionWorkspace = this.resolveWorkspace(workspaceUri); - const built = this._buildNewSessionData(sessionWorkspace, sessionType); - this._currentNewSession = built.data; - this._currentNewSessionStatus = built.status; - this._currentNewSessionModelId = built.modelId; - return this._chatToSession(built.data); - } - - /** - * Build a fresh {@link IChatData} for an untitled session rooted on - * {@link workspace} and targeting {@link sessionType}. The resource URI - * scheme is the unique per-connection resource scheme (looked up from - * {@link _sessionTypeToResourceScheme}), which routes to the correct - * chat session content provider. The logical session type (e.g. - * `copilotcli`) is stored as `ISession.sessionType`. - * - * Returns the status observable separately so callers can still drive - * state transitions (e.g. to {@link SessionStatus.InProgress}) without - * casting away the readonly declaration on {@link IChatData.status}. - */ - private _buildNewSessionData(workspace: ISessionWorkspace, sessionType: ISessionType): { data: IChatData; status: ISettableObservable; modelId: ISettableObservable } { - const workspaceUri = workspace.repositories[0]?.uri; - if (!workspaceUri) { - throw new Error('Workspace has no repository URI'); - } - const resourceScheme = this._sessionTypeToResourceScheme.get(sessionType.id) ?? sessionType.id; - const resource = URI.from({ scheme: resourceScheme, path: `/untitled-${generateUuid()}` }); - const status = observableValue(this, SessionStatus.Untitled); - const modelId = observableValue(this, undefined); - const loading = observableValue(this, true); - const data: IChatData = { - id: `${this.id}:${resource.toString()}`, - resource, - providerId: this.id, - sessionType: sessionType.id, - icon: Codicon.remote, - createdAt: new Date(), - workspace: observableValue(this, workspace), - title: observableValue(this, ''), - updatedAt: observableValue(this, new Date()), - status, - changes: observableValue(this, []), - modelId, - mode: observableValue(this, undefined), - loading, - isArchived: observableValue(this, false), - isRead: observableValue(this, true), - description: observableValue(this, undefined), - lastTurnEnd: observableValue(this, undefined), - gitHubInfo: observableValue(this, undefined), - }; - const agentProvider = this._agentProviderFromSessionType(sessionType.id); - this._newSessionWorkspaces.set(data.id, workspaceUri); - this._newSessionAgentProviders.set(data.id, agentProvider); - this._newSessionConfigs.set(data.id, { schema: { type: 'object', properties: {} }, values: {} }); - this._onDidChangeSessionConfig.fire(data.id); - this._resolveSessionConfig(data.id, agentProvider, workspaceUri, undefined); - return { data, status, modelId }; - } - - getSessionConfig(sessionId: string): IResolveSessionConfigResult | undefined { - const newSessionConfig = this._newSessionConfigs.get(sessionId); - if (newSessionConfig) { - return newSessionConfig; - } - this._ensureSessionStateSubscription(sessionId); - return this._runningSessionConfigs.get(sessionId); - } - - async setSessionConfigValue(sessionId: string, property: string, value: string): Promise { - // New session (pre-creation): re-resolve the full config schema - const workingDirectory = this._newSessionWorkspaces.get(sessionId); - if (workingDirectory) { - const current = this._newSessionConfigs.get(sessionId)?.values ?? {}; - this._newSessionConfigs.set(sessionId, { schema: { type: 'object', properties: {} }, values: { ...current, [property]: value } }); - this._setNewSessionLoading(sessionId, true); - this._onDidChangeSessionConfig.fire(sessionId); - await this._resolveSessionConfig(sessionId, this._getAgentProviderForSession(sessionId), workingDirectory, { ...current, [property]: value }); - return; - } - - // Running session: dispatch SessionConfigChanged for sessionMutable properties - const runningConfig = this._runningSessionConfigs.get(sessionId); - if (!runningConfig || !this._connection) { - return; - } - const schema = runningConfig.schema.properties[property]; - if (!schema?.sessionMutable) { - return; - } - - // Update local cache optimistically - this._runningSessionConfigs.set(sessionId, { - ...runningConfig, - values: { ...runningConfig.values, [property]: value }, - }); - this._onDidChangeSessionConfig.fire(sessionId); - - // Dispatch to the agent host connection - const rawId = this._rawIdFromChatId(sessionId); - const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (cached && rawId) { - const action = { type: ActionType.SessionConfigChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), config: { [property]: value } }; - this._connection.dispatch(action); - } - } - - async getSessionConfigCompletions(sessionId: string, property: string, query?: string): Promise { - const workingDirectory = this._newSessionWorkspaces.get(sessionId); - if (!workingDirectory || !this._connection) { - return []; - } - const result = await this._connection.sessionConfigCompletions({ - provider: this._getAgentProviderForSession(sessionId), - workingDirectory, - config: this._newSessionConfigs.get(sessionId)?.values, - property, - query, - }); - return result.items; - } - - getCreateSessionConfig(sessionId: string): Record | undefined { - return this._newSessionConfigs.get(sessionId)?.values; - } - - clearSessionConfig(sessionId: string): void { - this._clearNewSessionConfig(sessionId); - } - - - setModel(sessionId: string, modelId: string): void { - if (this._currentNewSession?.id === sessionId) { - this._selectedModelId = modelId; - this._currentNewSessionModelId?.set(modelId, undefined); - return; - } - - const rawId = this._rawIdFromChatId(sessionId); - const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (cached && rawId && this._connection) { - cached.modelId.set(modelId, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); - const resourceScheme = cached.resource.scheme; - const rawModelId = modelId.startsWith(`${resourceScheme}:`) ? modelId.substring(resourceScheme.length + 1) : modelId; - const model = cached.modelSelection?.id === rawModelId ? cached.modelSelection : { id: rawModelId }; - const action = { type: ActionType.SessionModelChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), model }; - this._connection.dispatch(action); - } - } - - // -- Session Actions -- - - async archiveSession(sessionId: string): Promise { - const rawId = this._rawIdFromChatId(sessionId); - const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (cached && rawId) { - cached.isArchived.set(true, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); - if (this._connection) { - const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: true }; - this._connection.dispatch(action); - } - } - } - - async unarchiveSession(sessionId: string): Promise { - const rawId = this._rawIdFromChatId(sessionId); - const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (cached && rawId) { - cached.isArchived.set(false, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); - if (this._connection) { - const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: false }; - this._connection.dispatch(action); - } - } - } - - async deleteSession(sessionId: string): Promise { - const rawId = this._rawIdFromChatId(sessionId); - const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (cached && rawId && this._connection) { - await this._connection.disposeSession(AgentSession.uri(cached.agentProvider, rawId)); - this._sessionCache.delete(rawId); - this._runningSessionConfigs.delete(sessionId); - this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(cached)], changed: [] }); - } - } - - async renameChat(sessionId: string, _chatUri: URI, _title: string): Promise { - const rawId = this._rawIdFromChatId(sessionId); - const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (cached && rawId && this._connection) { - cached.title.set(_title, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); - const action = { type: ActionType.SessionTitleChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), title: _title }; - this._connection.dispatch(action); - } - } - - async deleteChat(_sessionId: string, _chatUri: URI): Promise { - // Agent host sessions don't support deleting individual chats - } - - async sendAndCreateChat(chatId: string, options: ISendRequestOptions): Promise { - if (!this._connection) { - throw new Error(localize('notConnectedSend', "Cannot send request: not connected to remote agent host '{0}'.", this.label)); - } - - const session = this._currentNewSession; - if (!session || session.id !== chatId) { - throw new Error(`Session '${chatId}' not found or not a new session`); - } - - const { query, attachedContext } = options; - - const contribution = this._chatSessionsService.getChatSessionContribution(session.resource.scheme); - - const sendOptions: IChatSendRequestOptions = { - location: ChatAgentLocation.Chat, - userSelectedModelId: this._selectedModelId, - modeInfo: { - kind: ChatModeKind.Agent, - isBuiltin: true, - modeInstructions: undefined, - modeId: 'agent', - applyCodeBlockSuggestionId: undefined, - permissionLevel: undefined, - }, - agentIdSilent: contribution?.type, - attachedContext, - agentHostSessionConfig: this.getCreateSessionConfig(chatId), - }; - - // Open chat widget - await this._chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); - const chatWidget = await this._chatWidgetService.openSession(session.resource, ChatViewPaneTarget); - if (!chatWidget) { - throw new Error('[RemoteAgentHost] Failed to open chat widget'); - } - - // Load session model and apply selected model - const modelRef = await this._chatService.acquireOrLoadSession(session.resource, ChatAgentLocation.Chat, CancellationToken.None); - if (modelRef) { - if (this._selectedModelId) { - const languageModel = this._languageModelsService.lookupLanguageModel(this._selectedModelId); - if (languageModel) { - modelRef.object.inputModel.setState({ selectedModel: { identifier: this._selectedModelId, metadata: languageModel } }); - } - } - modelRef.dispose(); - } - - // Capture existing session keys before sending so we can detect the new - // backend session. Must be captured before sendRequest because the - // backend session may be created during the send and arrive via - // notification before sendRequest resolves. - const existingKeys = new Set(this._sessionCache.keys()); - - // Send request through the chat service, which delegates to the - // AgentHostSessionHandler content provider for turn handling - const result = await this._chatService.sendRequest(session.resource, query, sendOptions); - if (result.kind === 'rejected') { - throw new Error(`[RemoteAgentHost] sendRequest rejected: ${result.reason}`); - } - - // Add the untitled session to the pending set so it stays visible in the - // session list while the turn is in progress. It will be replaced - // by the committed session once the backend session appears. - this._currentNewSessionStatus?.set(SessionStatus.InProgress, undefined); - const newSession = this._chatToSession(session); - this._pendingSession = newSession; - this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] }); - - this._selectedModelId = undefined; - this._currentNewSessionStatus = undefined; - this._currentNewSessionModelId = undefined; - - // Wait for the real backend session to appear (via server notification - // after the handler creates it), then replace the temporary entry. - try { - const committedSession = await this._waitForNewSession(existingKeys); - if (committedSession) { - this._preserveSessionMutableConfig(chatId, committedSession.sessionId); - this._currentNewSession = undefined; - this._currentNewSessionModelId = undefined; - this._clearNewSessionConfig(chatId); - this._onDidReplaceSession.fire({ from: newSession, to: committedSession }); - return committedSession; - } - } catch { - // Connection lost or timeout — clean up - } finally { - this._pendingSession = undefined; - } - - // Fallback: keep the temp session visible - this._currentNewSession = undefined; - this._currentNewSessionModelId = undefined; - this._clearNewSessionConfig(chatId); - return newSession; - } - - addChat(_sessionId: string): IChat { - throw new Error('Multiple chats per session is not supported for remote agent host sessions'); - } - - async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise { - throw new Error('Multiple chats per session is not supported for remote agent host sessions'); - } - - private async _resolveSessionConfig(sessionId: string, agentProvider: string, workingDirectory: URI, config: Record | undefined): Promise { - if (!this._connection) { - this._setNewSessionLoading(sessionId, false); - return; - } - const request = (this._newSessionConfigRequests.get(sessionId) ?? 0) + 1; - this._newSessionConfigRequests.set(sessionId, request); - try { - const result = await this._connection.resolveSessionConfig({ - provider: agentProvider, - workingDirectory, - config, - }); - if (this._newSessionConfigRequests.get(sessionId) !== request) { - return; - } - this._newSessionConfigs.set(sessionId, result); - this._setNewSessionLoading(sessionId, !isSessionConfigComplete(result)); - } catch { - if (this._newSessionConfigRequests.get(sessionId) !== request) { - return; - } - this._newSessionConfigs.delete(sessionId); - this._setNewSessionLoading(sessionId, false); - } - this._onDidChangeSessionConfig.fire(sessionId); - } - - private _clearNewSessionConfig(sessionId: string): void { - this._newSessionWorkspaces.delete(sessionId); - this._newSessionConfigs.delete(sessionId); - this._newSessionAgentProviders.delete(sessionId); - this._newSessionConfigRequests.delete(sessionId); - } - - /** - * When a session transitions from untitled (new) to committed (running), - * preserve the session-mutable config properties so they can be changed - * during the running session. - */ - private _preserveSessionMutableConfig(oldSessionId: string, newSessionId: string): void { - const config = this._newSessionConfigs.get(oldSessionId); - if (!config) { - return; - } - // Filter schema to only include session-mutable properties - const mutableProperties: IResolveSessionConfigResult['schema']['properties'] = {}; - const mutableValues: Record = {}; - for (const [key, propSchema] of Object.entries(config.schema.properties)) { - if (propSchema.sessionMutable) { - mutableProperties[key] = propSchema; - if (Object.hasOwn(config.values, key)) { - mutableValues[key] = config.values[key]; - } - } - } - if (Object.keys(mutableProperties).length > 0) { - this._runningSessionConfigs.set(newSessionId, { - schema: { type: 'object', properties: mutableProperties }, - values: mutableValues, - }); - } - } - - // -- Private: Session Cache -- - - private _cacheInitialized = false; - - private _ensureSessionCache(): void { - if (this._cacheInitialized) { - return; - } - this._cacheInitialized = true; - const cts = new CancellationTokenSource(); - this._refreshSessions(cts.token).finally(() => cts.dispose()); - } - - private async _refreshSessions(_token: unknown): Promise { - if (!this._connection) { - return; - } - try { - const sessions = await this._connection.listSessions(); - const currentKeys = new Set(); - const added: ISession[] = []; - const changed: ISession[] = []; - - for (const meta of sessions) { - const rawId = AgentSession.id(meta.session); - currentKeys.add(rawId); - - const existing = this._sessionCache.get(rawId); - if (existing) { - existing.update(meta); - changed.push(this._chatToSession(existing)); - } else { - const resourceScheme = this._resourceSchemeForMetadata(meta); - const logicalType = this._logicalSessionTypeForMetadata(meta); - const cached = new RemoteSessionAdapter(meta, this.id, resourceScheme, logicalType, this.label, toLocalDiffUri(this._connectionAuthority)); - this._sessionCache.set(rawId, cached); - added.push(this._chatToSession(cached)); - } - } - - const removed: ISession[] = []; - for (const [key, cached] of this._sessionCache) { - if (!currentKeys.has(key)) { - this._sessionCache.delete(key); - this._runningSessionConfigs.delete(cached.id); - removed.push(this._chatToSession(cached)); - } - } - - if (added.length > 0 || removed.length > 0 || changed.length > 0) { - this._onDidChangeSessions.fire({ added, removed, changed }); - } - } catch { - // Connection may not be ready yet - } - } - - /** - * Wait for a new session to appear in the cache that wasn't present before. - * Tries an immediate refresh, then listens for the session-added notification. - * Returns `undefined` if the connection is lost or a timeout expires. - */ - private async _waitForNewSession(existingKeys: Set): Promise { - // First, try an immediate refresh - await this._refreshSessions(CancellationToken.None); - for (const [key, cached] of this._sessionCache) { - if (!existingKeys.has(key)) { - return this._chatToSession(cached); - } - } - - // If not found yet, wait for the next onDidChangeSessions event, - // bounded by a timeout and aborted on disconnect. - const waitDisposables = new DisposableStore(); - try { - const sessionPromise = new Promise((resolve) => { - waitDisposables.add(this._onDidChangeSessions.event(e => { - const newSession = e.added.find(s => { - const rawId = s.resource.path.substring(1); - return !existingKeys.has(rawId); - }); - if (newSession) { - resolve(newSession); - } - })); - waitDisposables.add(this._onDidDisconnect.event(() => resolve(undefined))); - }); - return await raceTimeout(sessionPromise, 30_000); - } finally { - waitDisposables.dispose(); - } - } - - private _handleSessionAdded(summary: ISessionSummary): void { - const sessionUri = URI.parse(summary.resource); - const rawId = AgentSession.id(sessionUri); - if (this._sessionCache.has(rawId)) { - return; - } - - const workingDir = typeof summary.workingDirectory === 'string' - ? toAgentHostUri(URI.parse(summary.workingDirectory), this._connectionAuthority) - : undefined; - const meta: IAgentSessionMetadata = { - session: sessionUri, - startTime: summary.createdAt, - modifiedTime: summary.modifiedAt, - summary: summary.title, - ...(summary.project ? { project: { uri: toLocalProjectUri(URI.parse(summary.project.uri), this._connectionAuthority), displayName: summary.project.displayName } } : {}), - model: summary.model, - workingDirectory: workingDir, - isRead: summary.isRead, - isDone: summary.isDone, - }; - const resourceScheme = this._resourceSchemeForMetadata(meta); - const logicalType = this._logicalSessionTypeForMetadata(meta); - const cached = new RemoteSessionAdapter(meta, this.id, resourceScheme, logicalType, this.label, toLocalDiffUri(this._connectionAuthority)); - this._sessionCache.set(rawId, cached); - this._onDidChangeSessions.fire({ added: [this._chatToSession(cached)], removed: [], changed: [] }); - } - - private _handleSessionRemoved(session: URI | string): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - this._sessionCache.delete(rawId); - this._runningSessionConfigs.delete(cached.id); - this._sessionStateSubscriptions.deleteAndDispose(cached.id); - this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(cached)], changed: [] }); - } - } - - private _handleTitleChanged(session: string, title: string): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - cached.title.set(title, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); - } - } - - private _handleModelChanged(session: string, model: IModelSelection): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - cached.modelSelection = model; - } - const modelId = cached ? `${cached.resource.scheme}:${model.id}` : undefined; - if (cached && cached.modelId.get() !== modelId) { - cached.modelId.set(modelId, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); - } - } - - private _handleIsReadChanged(session: string, isRead: boolean): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - cached.isRead.set(isRead, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); - } - } - - private _handleIsDoneChanged(session: string, isDone: boolean): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - cached.isArchived.set(isDone, undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); - } - } - - private _handleDiffsChanged(session: string, diffs: IFileEdit[]): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (cached) { - const mapUri = toLocalDiffUri(this._connectionAuthority); - cached.changes.set(diffsToChanges(diffs, mapUri), undefined); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); - } - } - - private _handleSessionSummaryChanged(session: string, changes: Partial): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (!cached) { - return; - } - - let didChange = false; - - if (changes.status !== undefined) { - const uiStatus = mapProtocolStatus(changes.status); - if (uiStatus !== cached.status.get()) { - cached.status.set(uiStatus, undefined); - didChange = true; - } - } - - if (changes.title !== undefined && changes.title !== cached.title.get()) { - cached.title.set(changes.title, undefined); - didChange = true; - } - - if (changes.diffs !== undefined) { - const mapUri = toLocalDiffUri(this._connectionAuthority); - if (!diffsEqual(cached.changes.get(), changes.diffs, mapUri)) { - cached.changes.set(diffsToChanges(changes.diffs, mapUri), undefined); - didChange = true; - } - } - - if (didChange) { - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); - } - } - - private _handleConfigChanged(session: string, config: Record): void { - const rawId = AgentSession.id(session); - const cached = this._sessionCache.get(rawId); - if (!cached) { - return; - } - const sessionId = cached.id; - const existing = this._runningSessionConfigs.get(sessionId); - if (existing) { - this._runningSessionConfigs.set(sessionId, { - ...existing, - values: { ...existing.values, ...config }, - }); - } else { - // Session was restored (e.g. after reconnect) — create a minimal - // config entry from the changed values so the picker can render. - this._runningSessionConfigs.set(sessionId, { - schema: { type: 'object', properties: buildMutableConfigSchema(config) }, - values: config, - }); - } - this._onDidChangeSessionConfig.fire(sessionId); - } - - /** - * Lazily acquire a session-state subscription for `sessionId` so that - * `_runningSessionConfigs` is seeded from the AHP `ISessionState.config` - * snapshot. Safe to call repeatedly — no-op once a subscription exists. - * - * The subscription is reference-counted by {@link IAgentConnection.getSubscription}, - * so when the session handler is also subscribed (chat content open) this - * shares the existing wire subscription rather than opening a new one. - * Subscriptions are cleared on connection replace via - * {@link _sessionStateSubscriptions} so reconnects re-seed from a fresh snapshot. - */ - private _ensureSessionStateSubscription(sessionId: string): void { - if (this._sessionStateSubscriptions.has(sessionId)) { - return; - } - if (!this._connection) { - return; - } - const rawId = this._rawIdFromChatId(sessionId); - if (!rawId) { - return; - } - const cached = this._sessionCache.get(rawId); - if (!cached) { - return; - } - const sessionUri = AgentSession.uri(cached.agentProvider, rawId); - const ref = this._connection.getSubscription(StateComponents.Session, sessionUri); - const store = new DisposableStore(); - store.add(ref); - store.add(ref.object.onDidChange(state => this._seedRunningConfigFromState(sessionId, state))); - this._sessionStateSubscriptions.set(sessionId, store); - - const value = ref.object.value; - if (value && !(value instanceof Error)) { - this._seedRunningConfigFromState(sessionId, value); - } - } - - /** - * Filter `state.config` to session-mutable properties and update - * {@link _runningSessionConfigs} if changed. No-op if the seeded value is - * structurally equal to the existing entry to avoid spurious - * `onDidChangeSessionConfig` fires. - */ - private _seedRunningConfigFromState(sessionId: string, state: ISessionState): void { - const stateConfig = state.config; - if (!stateConfig) { - return; - } - const properties: Record = {}; - const values: Record = {}; - for (const [key, propSchema] of Object.entries(stateConfig.schema.properties)) { - if (!propSchema.sessionMutable) { - continue; - } - properties[key] = propSchema; - if (Object.hasOwn(stateConfig.values, key)) { - values[key] = stateConfig.values[key]; - } - } - if (Object.keys(properties).length === 0) { - return; - } - const seeded: IResolveSessionConfigResult = { - schema: { type: 'object', properties }, - values, - }; - const existing = this._runningSessionConfigs.get(sessionId); - if (existing && resolvedConfigsEqual(existing, seeded)) { - return; - } - this._runningSessionConfigs.set(sessionId, seeded); - this._onDidChangeSessionConfig.fire(sessionId); - } - - private _rawIdFromChatId(chatId: string): string | undefined { - const prefix = `${this.id}:`; - const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId; - try { - return URI.parse(resourceStr).path.substring(1) || undefined; - } catch { - return undefined; - } - } - - private _setNewSessionLoading(sessionId: string, loading: boolean): void { - if (this._currentNewSession?.id === sessionId) { - this._currentNewSession.loading.set(loading, undefined); - } - } - - private _agentProviderFromSessionType(sessionType: string): string { - return wellKnownAgentProvider(sessionType) ?? sessionType.substring(`remote-${this._connectionAuthority}-`.length); - } - - private _getAgentProviderForSession(sessionId: string): string { - return this._newSessionAgentProviders.get(sessionId) ?? DEFAULT_AGENT_HOST_PROVIDER; - } - // -- Private: Browse -- + // -- Browse -------------------------------------------------------------- private async _browseForFolder(): Promise { // Establish connection on demand if a hook is provided (e.g. tunnel relay) @@ -1412,46 +392,4 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen } return undefined; } - - private _chatToSession(chat: IChatData): ISession { - const mainChat: IChat = { - resource: chat.resource, - createdAt: chat.createdAt, - title: chat.title, - updatedAt: chat.updatedAt, - status: chat.status, - changes: chat.changes, - modelId: chat.modelId, - mode: chat.mode, - isArchived: chat.isArchived, - isRead: chat.isRead, - description: chat.description, - lastTurnEnd: chat.lastTurnEnd, - }; - const session: ISession = { - sessionId: chat.id, - resource: chat.resource, - providerId: chat.providerId, - sessionType: chat.sessionType, - icon: chat.icon, - createdAt: chat.createdAt, - workspace: chat.workspace, - title: chat.title, - updatedAt: chat.updatedAt, - status: chat.status, - changes: chat.changes, - modelId: chat.modelId, - mode: chat.mode, - loading: derived(reader => chat.loading.read(reader) || this._authenticationPending.read(reader)), - isArchived: chat.isArchived, - isRead: chat.isRead, - description: chat.description, - lastTurnEnd: chat.lastTurnEnd, - gitHubInfo: chat.gitHubInfo, - chats: constObservable([mainChat]), - mainChat, - capabilities: { supportsMultipleChats: false }, - }; - return session; - } } diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 9dc4b5ac3a6..3175f775b7e 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -207,7 +207,7 @@ import './contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.j import './contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.js'; // Local Agent Host -import './contrib/localAgentHost/browser/localAgentHost.contribution.js'; +import './contrib/agentHost/browser/localAgentHost.contribution.js'; // Tunnel Host (allow remote connections to local agent host) import './contrib/tunnelHost/electron-browser/tunnelHost.contribution.js'; From 6b03605276a605636f3bd81c8829c2ec2eb2dc0e Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 19 Apr 2026 14:04:46 -0700 Subject: [PATCH 009/114] Fix subagent tool call grouping, auto-approval, and agent name display (#311258) * Fix subagent tool call grouping, auto-approval, and agent name display Multiple fixes for subagent UI behavior in the agent host: 1. Tool call grouping: inner subagent tool calls were appearing flat at the top level instead of nested under the parent. Fixed event-ordering buffer in agentSideEffects.ts so 'tool_start' events arriving before 'subagent_started' get routed correctly. 2. Duplicate UI containers: the parent task tool was rendered as a regular tool widget alongside a separate generic Subagent container. Root cause: when the parent was first observed in PendingConfirmation, stateToProgressAdapter.ts created the live ChatToolInvocation without subagent toolSpecificData. Now subagent kind is detected at all entry points (PendingConfirmation, updateRunningToolSpecificData, finalizeToolInvocation) via getToolKind() or isSubagentToolName(). 3. Auto-approval: tools inside subagent sessions were prompting for confirmation because subagent sessions don't carry workingDirectory or autoApprove config. _tryAutoApproveToolReady now falls back to the parent session's config when the sessionKey is a subagent URI. 4. Agent name regression: the parent renderer fell back to a generic 'Subagent' label instead of showing the real agent name. Two fixes: (a) chatSubagentContentPart.ts: removed the _isDefaultDescription gate on the autorun update path so agentName/description get updated independently when the SDK's subagent_started event arrives later. (b) agentEventMapper.ts / agentService.ts: also read args.agent_type (the Copilot SDK task tool's field) as a fallback for agentName, so the name is populated eagerly from the tool args. Tests: - 3 new unit tests in agentHostChatContribution.test.ts (subagent grouping suite) covering inner tool ordering, PendingConfirmation transition, and agent name update. - 2 new unit tests in agentSideEffects.test.ts covering auto-approval inheritance for workingDirectory and session-level autoApprove. - New real-SDK integration test in toolApprovalRealSdk.integrationTest.ts for end-to-end subagent routing (gated by AGENT_HOST_REAL_SDK=1). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move SDK-specific subagent arg shape into the Copilot adapter The previous fix for the missing agent name added Copilot SDK-specific arg parsing (`agent_type` fallback) directly into the generic agentEventMapper and AgentService. agentEventMapper.ts is supposed to stay generic across agent the per-SDK adapter should normalize its arg shape.SDKs Changes: - Add `subagentAgentName` and `subagentDescription` fields to the generic IAgentToolStartEvent interface so adapters can attach already-normalized metadata at the source. - New `getSubagentMetadata()` helper in copilot/copilotToolDisplay.ts that knows about the Copilot SDK's `agent_type` field. - copilotAgentSession.ts and copilot/mapSessionEvents.ts now call this helper at tool_start emission and populate the new fields. - agentEventMapper.ts now just forwards the fields into _ no moremeta SDK-specific arg parsing in the generic layer. - agentService.ts `extractSubagentMeta` simplified to read the same fields off the event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clean up subagent adapter: drop dead PendingConfirmation paths, add WS test The PendingConfirmation handling for subagent tools in stateToProgressAdapter was defensive code with no production the Copilot SDK's `task`trigger tool never requests permission, and the event mapper auto-emits `tool_ready` with `confirmed: NotNeeded` paired with `tool_start`. So subagent-spawning tools never enter PendingConfirmation in production. Cleanup: - Drop the PendingConfirmation subagent branch in toolCallStateToInvocation. - Drop the "upgrade non-subagent invocation to subagent" fallback in updateRunningToolSpecificData and finalizeToolInvocation. Invocations now always get subagent toolSpecificData at creation time. - Replace inline `getToolKind(tc) === 'subagent' || isSubagentToolName(...)` checks with a single `isSubagentTool(tc)` helper. - Remove the synthetic PendingConfirmation grouping test that drove that dead code path; the realistic [Start, Ready(NotNeeded)] flow is already covered by other tests in the suite. Also add an end-to-end WebSocket integration test that exercises subagent routing through the real protocol stack: ScriptedMockAgent gains a new `subagent` prompt that emits a `task` tool_start + `subagent_started` + inner `tool_start` with parentToolCallId, and the test asserts that the inner tool call lands in the child subagent session (not flat in the parent). (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CI failures and address Copilot review feedback - Fix two unit tests broken by SDK-specific arg parsing refactor (mapper now forwards subagentDescription/subagentAgentName instead of extracting them from toolArguments) - Defer _toolCallAgents registration for inner subagent tool calls until subagent routing succeeds, so a later tool_ready (which lacks parentToolCallId) cannot be routed against the parent session - Downgrade buffering/draining log lines from info to trace - Use buildSubagentSessionUri() helper in test instead of manual string concat - Fix stale doc comment (completed -> running) (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Drop dead `agentName` fallback from getSubagentMetadata The Copilot SDK's `task` tool only uses `agent_type`. The `agentName` camelCase fallback was never actually populated by the SDK. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add unit tests for ChatSubagentContentPart late metadata updates Cover the autorun path that re-reads metadata when toolSpecificData is updated after the part was first constructed (e.g. when subagent_started arrives later than the initial PendingConfirmation render): real description arrives later agentName arrives later (regression test for the bug fixed in 27966ce868f) later update without agentName must not clear title preserved Verified that the regression test fails when the fix is reverted to the old _isDefaultDescription gate. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clear pending subagent event buffer when parent tool completes Previously, if an inner subagent `tool_start` was buffered in `_pendingSubagentEvents` and the parent tool completed before any `subagent_started` arrived, the buffer entry would leak until the parent session was disposed. `completeSubagentSession` now drops the buffer even when no subagent session was created. Adds a regression test verifying that a late `subagent_started` after parent `tool_complete` does not replay stale buffered events. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/common/agentService.ts | 12 + .../agentHost/node/agentEventMapper.ts | 20 +- .../platform/agentHost/node/agentService.ts | 21 +- .../agentHost/node/agentSideEffects.ts | 295 ++++++++++++------ .../node/copilot/copilotAgentSession.ts | 5 +- .../node/copilot/copilotToolDisplay.ts | 22 ++ .../node/copilot/mapSessionEvents.ts | 5 +- .../test/node/agentEventMapper.test.ts | 8 +- .../agentHost/test/node/agentService.test.ts | 2 +- .../test/node/agentSideEffects.test.ts | 180 ++++++++++- .../platform/agentHost/test/node/mockAgent.ts | 53 ++++ .../toolApprovalRealSdk.integrationTest.ts | 112 ++++++- .../protocol/turnExecution.integrationTest.ts | 34 +- .../agentHost/agentHostSessionHandler.ts | 5 +- .../agentHost/stateToProgressAdapter.ts | 71 +++-- .../chatSubagentContentPart.ts | 21 +- .../agentHostChatContribution.test.ts | 204 +++++++++++- .../chatSubagentContentPart.test.ts | 94 ++++++ 18 files changed, 1000 insertions(+), 164 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 2b6f2c7a9b3..c1c6947d7bf 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -233,6 +233,18 @@ export interface IAgentToolStartEvent extends IAgentProgressEventBase { readonly language?: string; /** Serialized JSON of the tool arguments, if available. */ readonly toolArguments?: string; + /** + * For `toolKind === 'subagent'`, the internal name of the agent being + * spawned (e.g. 'explore'). Adapters are responsible for extracting this + * from their SDK-specific tool argument shape. + */ + readonly subagentAgentName?: string; + /** + * For `toolKind === 'subagent'`, a human-readable description of the + * subagent's task. Adapters are responsible for extracting this from + * their SDK-specific tool argument shape. + */ + readonly subagentDescription?: string; readonly mcpServerName?: string; readonly mcpToolName?: string; readonly parentToolCallId?: string; diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts index e662c38c8e3..402457ddd22 100644 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -96,18 +96,14 @@ export class AgentEventMapper { const e = event as IAgentToolStartEvent; const meta: Record = { toolKind: e.toolKind, language: e.language }; - // For subagent tools, extract agent metadata from tool arguments - // so the renderer can display the name/description immediately. - if (e.toolKind === 'subagent' && e.toolArguments) { - try { - const args = JSON.parse(e.toolArguments) as Record; - if (typeof args.description === 'string') { - meta.subagentDescription = args.description; - } - if (typeof args.agentName === 'string') { - meta.subagentAgentName = args.agentName; - } - } catch { /* ignore parse errors */ } + // Subagent metadata is normalized by the per-SDK adapter (e.g. + // the Copilot adapter maps `agent_type` → `subagentAgentName`), + // so the generic mapper just forwards it as-is. + if (e.subagentDescription) { + meta.subagentDescription = e.subagentDescription; + } + if (e.subagentAgentName) { + meta.subagentAgentName = e.subagentAgentName; } const startAction: IToolCallStartAction = { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index b4803f635ce..b451497d536 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -30,22 +30,19 @@ import { AgentHostStateManager } from './agentHostStateManager.js'; * on the provider identifier in the session configuration. */ /** - * Extracts subagent metadata from a tool start event's arguments, - * matching the event mapper's extraction for the eager toolKind path. + * Extracts subagent metadata from a tool start event. Adapters are + * responsible for normalizing their SDK-specific argument shape into the + * generic `subagentAgentName` / `subagentDescription` fields on the event + * itself, so this just forwards them. */ function extractSubagentMeta(start: IAgentToolStartEvent | undefined): { subagentDescription?: string; subagentAgentName?: string } { - if (!start?.toolKind || start.toolKind !== 'subagent' || !start.toolArguments) { - return {}; - } - try { - const args = JSON.parse(start.toolArguments) as Record; - return { - subagentDescription: typeof args.description === 'string' && args.description.length > 0 ? args.description : undefined, - subagentAgentName: typeof args.agentName === 'string' && args.agentName.length > 0 ? args.agentName : undefined, - }; - } catch { + if (!start) { return {}; } + return { + subagentDescription: start.subagentDescription, + subagentAgentName: start.subagentAgentName, + }; } export class AgentService extends Disposable implements IAgentService { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 421eeec3191..82c574413c1 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -27,6 +27,7 @@ import { ToolResultContentType, buildSubagentSessionUri, getToolFileEdits, + parseSubagentSessionUri, type ISessionCustomization, type ISessionState, type IToolResultContent, @@ -50,6 +51,13 @@ export interface IAgentSideEffectsOptions { readonly sessionDataService: ISessionDataService; } +/** A progress event that was deferred because its subagent session does not exist yet. */ +interface IPendingSubagentEvent { + readonly event: IAgentProgressEvent; + readonly agent: IAgent; + readonly agentMapper: AgentEventMapper; +} + /** * Shared implementation of agent side-effect handling. * @@ -83,6 +91,19 @@ export class AgentSideEffects extends Disposable { */ private readonly _subagentSessions = new Map(); + /** + * Buffers progress events whose `parentToolCallId` references a subagent + * whose `subagent_started` event has not yet been processed. The SDK is + * not strict about ordering: an inner `tool_start` can arrive before the + * `subagent_started` that creates the child session. Without buffering, + * those events would be dispatched against the parent session and the + * UI would render the inner tool calls flat at the top level rather than + * grouping them under the subagent. Drained by `_handleSubagentStarted`. + * + * Key: `${parentSession}:${parentToolCallId}`. + */ + private readonly _pendingSubagentEvents = new Map(); + constructor( private readonly _stateManager: AgentHostStateManager, private readonly _options: IAgentSideEffectsOptions, @@ -176,10 +197,19 @@ export class AgentSideEffects extends Disposable { sessionKey: ProtocolURI, agent: IAgent, ): boolean { + // Subagent sessions don't carry their own config or workingDirectory — + // inherit from the parent session so auto-approval rules apply + // uniformly to tool calls inside subagents. + const sessionState = this._stateManager.getSessionState(sessionKey); + const parentInfo = parseSubagentSessionUri(sessionKey); + const parentState = parentInfo ? this._stateManager.getSessionState(parentInfo.parentSession) : undefined; + const autoApproveLevel = sessionState?.config?.values?.autoApprove + ?? parentState?.config?.values?.autoApprove; + const workDir = sessionState?.summary.workingDirectory + ?? parentState?.summary.workingDirectory; + // Session-level auto-approve: when the user has set "Bypass Approvals" // or "Autopilot", auto-approve all tool calls unconditionally. - const sessionState = this._stateManager.getSessionState(sessionKey); - const autoApproveLevel = sessionState?.config?.values?.autoApprove; if (autoApproveLevel === 'autoApprove' || autoApproveLevel === 'autopilot') { this._logService.trace(`[AgentSideEffects] Auto-approving tool call (session autoApprove=${autoApproveLevel})`); this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`); @@ -189,7 +219,6 @@ export class AgentSideEffects extends Disposable { // Read auto-approval: approve reads inside the session's working directory. if (e.permissionKind === 'read' && e.permissionPath) { - const workDir = sessionState?.summary.workingDirectory; const workingDirectory = workDir ? URI.parse(workDir) : undefined; if (workingDirectory && extUriBiasedIgnorePathCase.isEqualOrParent(normalizePath(URI.file(e.permissionPath)), workingDirectory)) { this._logService.trace(`[AgentSideEffects] Auto-approving read of ${e.permissionPath}`); @@ -203,7 +232,6 @@ export class AgentSideEffects extends Disposable { // Write auto-approval: only within the session's working directory, // then apply the default glob patterns for protected files. if (e.permissionKind === 'write' && e.permissionPath) { - const workDir = sessionState?.summary.workingDirectory; const workingDirectory = workDir ? URI.parse(workDir) : undefined; if (workingDirectory && extUriBiasedIgnorePathCase.isEqualOrParent(normalizePath(URI.file(e.permissionPath)), workingDirectory)) { if (this._shouldAutoApproveEdit(e.permissionPath)) { @@ -252,118 +280,169 @@ export class AgentSideEffects extends Disposable { } const agentMapper = mapper; disposables.add(agent.onDidSessionProgress(e => { - // Track tool calls so handleAction can route confirmations - if (e.type === 'tool_start') { - this._toolCallAgents.set(`${e.session.toString()}:${e.toolCallId}`, agent.id); - } + this._handleAgentProgress(agent, agentMapper, e); + })); + return disposables; + } - const sessionKey = e.session.toString(); + /** + * Routes a single progress event from `agent` to the correct session. + * + * Events with a `parentToolCallId` are routed to the matching subagent + * session. If the subagent session does not exist yet (the SDK can emit + * an inner `tool_start` before its `subagent_started`), the event is + * buffered in `_pendingSubagentEvents` and replayed once the + * `subagent_started` arrives. + */ + private _handleAgentProgress(agent: IAgent, agentMapper: AgentEventMapper, e: IAgentProgressEvent): void { + const sessionKey = e.session.toString(); - // Handle subagent_started: create the subagent session - if (e.type === 'subagent_started') { - this._handleSubagentStarted(sessionKey, e.toolCallId, e.agentName, e.agentDisplayName, e.agentDescription); - return; - } + // Track tool calls so handleAction can route confirmations. Defer + // registration for inner subagent tool calls (those carrying a + // `parentToolCallId`) until we know which subagent session they + // belong to — otherwise we'd register them under the parent + // session key and a later `tool_ready` (which lacks + // `parentToolCallId`) could be routed against the wrong session. + if (e.type === 'tool_start' && !this._getParentToolCallId(e)) { + this._toolCallAgents.set(`${sessionKey}:${e.toolCallId}`, agent.id); + } - // Route events with parentToolCallId to the subagent session - const parentToolCallId = this._getParentToolCallId(e); - if (parentToolCallId) { - const subagentKey = `${sessionKey}:${parentToolCallId}`; - const subagentSession = this._subagentSessions.get(subagentKey); - if (subagentSession) { - // Track tool calls in subagent context for confirmation routing - if (e.type === 'tool_start') { - this._toolCallAgents.set(`${subagentSession}:${e.toolCallId}`, agent.id); - } - const subTurnId = this._stateManager.getActiveTurnId(subagentSession); - if (subTurnId) { - if (e.type === 'tool_ready') { - if (this._tryAutoApproveToolReady(e, subagentSession, agent)) { - return; - } - } - this._dispatchProgressActions(agentMapper, e, subagentSession, subTurnId); - } - return; + // Handle subagent_started: create the subagent session, then drain + // any inner events that arrived before us. + if (e.type === 'subagent_started') { + this._handleSubagentStarted(sessionKey, e.toolCallId, e.agentName, e.agentDisplayName, e.agentDescription); + this._drainPendingSubagentEvents(sessionKey, e.toolCallId); + return; + } + + // Route events with parentToolCallId to the subagent session. + const parentToolCallId = this._getParentToolCallId(e); + if (parentToolCallId) { + const subagentKey = `${sessionKey}:${parentToolCallId}`; + const subagentSession = this._subagentSessions.get(subagentKey); + if (subagentSession) { + // Track tool calls in subagent context for confirmation routing + if (e.type === 'tool_start') { + this._toolCallAgents.set(`${subagentSession}:${e.toolCallId}`, agent.id); } - } - - // Route tool_ready events for tools inside subagent sessions - // (tool_ready lacks parentToolCallId, but the tool was previously - // registered under its subagent session key in _toolCallAgents) - if (e.type === 'tool_ready') { - const subagentSession = this._findSubagentSessionForToolCall(sessionKey, e.toolCallId); - if (subagentSession) { - const subTurnId = this._stateManager.getActiveTurnId(subagentSession); - if (subTurnId) { + const subTurnId = this._stateManager.getActiveTurnId(subagentSession); + if (subTurnId) { + if (e.type === 'tool_ready') { if (this._tryAutoApproveToolReady(e, subagentSession, agent)) { return; } - this._dispatchProgressActions(agentMapper, e, subagentSession, subTurnId); } + this._dispatchProgressActions(agentMapper, e, subagentSession, subTurnId); + } + return; + } + + // Subagent session does not exist yet — buffer the event so we + // can replay it after `subagent_started` arrives. Without this, + // inner tool calls would leak into the parent session and the + // UI would render them flat at the top level. + this._logService.trace(`[AgentSideEffects] Buffering ${e.type} for pending subagent ${subagentKey}`); + let buffer = this._pendingSubagentEvents.get(subagentKey); + if (!buffer) { + buffer = []; + this._pendingSubagentEvents.set(subagentKey, buffer); + } + buffer.push({ event: e, agent, agentMapper }); + return; + } + + // Route tool_ready events for tools inside subagent sessions + // (tool_ready lacks parentToolCallId, but the tool was previously + // registered under its subagent session key in _toolCallAgents) + if (e.type === 'tool_ready') { + const subagentSession = this._findSubagentSessionForToolCall(sessionKey, e.toolCallId); + if (subagentSession) { + const subTurnId = this._stateManager.getActiveTurnId(subagentSession); + if (subTurnId) { + if (this._tryAutoApproveToolReady(e, subagentSession, agent)) { + return; + } + this._dispatchProgressActions(agentMapper, e, subagentSession, subTurnId); + } + return; + } + } + + const turnId = this._stateManager.getActiveTurnId(sessionKey); + if (turnId) { + // Auto-approve tool_ready events synchronously before dispatching. + // Tree-sitter is pre-warmed via initialize(), so this is fully sync. + if (e.type === 'tool_ready') { + if (this._tryAutoApproveToolReady(e, sessionKey, agent)) { return; } } - const turnId = this._stateManager.getActiveTurnId(sessionKey); - if (turnId) { - // Auto-approve tool_ready events synchronously before dispatching. - // Tree-sitter is pre-warmed via initialize(), so this is fully sync. - if (e.type === 'tool_ready') { - if (this._tryAutoApproveToolReady(e, sessionKey, agent)) { - return; - } - } - - // When a parent tool call has an associated subagent session, - // preserve the subagent content metadata in the completion - // result. The SDK's tool_complete provides its own content - // which would overwrite the IToolResultSubagentContent that - // was set via SessionToolCallContentChanged while running. - if (e.type === 'tool_complete') { - const subagentKey = `${sessionKey}:${e.toolCallId}`; - const subagentUri = this._subagentSessions.get(subagentKey); - if (subagentUri) { - const parentState = this._stateManager.getSessionState(sessionKey); - const runningContent = this._getRunningToolCallContent(parentState, turnId, e.toolCallId); - const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); - if (subagentEntry) { - const mergedContent = [...(e.result.content ?? []), subagentEntry]; - e = { ...e, result: { ...e.result, content: mergedContent } }; - } - } - } - - this._dispatchProgressActions(agentMapper, e, sessionKey, turnId); - - // When a parent tool call completes, complete any associated subagent session - if (e.type === 'tool_complete') { - this.completeSubagentSession(sessionKey, e.toolCallId); - if (getToolFileEdits((e as IAgentToolCompleteEvent).result).length > 0) { - this._scheduleDebouncedDiffComputation(sessionKey, turnId); + // When a parent tool call has an associated subagent session, + // preserve the subagent content metadata in the completion + // result. The SDK's tool_complete provides its own content + // which would overwrite the IToolResultSubagentContent that + // was set via SessionToolCallContentChanged while running. + if (e.type === 'tool_complete') { + const subagentKey = `${sessionKey}:${e.toolCallId}`; + const subagentUri = this._subagentSessions.get(subagentKey); + if (subagentUri) { + const parentState = this._stateManager.getSessionState(sessionKey); + const runningContent = this._getRunningToolCallContent(parentState, turnId, e.toolCallId); + const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); + if (subagentEntry) { + const mergedContent = [...(e.result.content ?? []), subagentEntry]; + e = { ...e, result: { ...e.result, content: mergedContent } }; } } } - // After a turn completes (idle event), flush any pending debounced - // diff computation and compute final diffs immediately. - if (e.type === 'idle') { - this._cancelDebouncedDiffComputation(sessionKey); - this._computeSessionDiffs(sessionKey, turnId); - this._tryConsumeNextQueuedMessage(sessionKey); - } + this._dispatchProgressActions(agentMapper, e, sessionKey, turnId); - // Steering message was consumed by the agent — remove from protocol state - if (e.type === 'steering_consumed') { - this._stateManager.dispatchServerAction({ - type: ActionType.SessionPendingMessageRemoved, - session: sessionKey, - kind: PendingMessageKind.Steering, - id: e.id, - }); + // When a parent tool call completes, complete any associated subagent session + if (e.type === 'tool_complete') { + this.completeSubagentSession(sessionKey, e.toolCallId); + if (getToolFileEdits((e as IAgentToolCompleteEvent).result).length > 0) { + this._scheduleDebouncedDiffComputation(sessionKey, turnId); + } } - })); - return disposables; + } + + // After a turn completes (idle event), flush any pending debounced + // diff computation and compute final diffs immediately. + if (e.type === 'idle') { + this._cancelDebouncedDiffComputation(sessionKey); + this._computeSessionDiffs(sessionKey, turnId); + this._tryConsumeNextQueuedMessage(sessionKey); + } + + // Steering message was consumed by the agent — remove from protocol state + if (e.type === 'steering_consumed') { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionPendingMessageRemoved, + session: sessionKey, + kind: PendingMessageKind.Steering, + id: e.id, + }); + } + } + + /** + * Replays any progress events that were buffered while waiting for + * `subagent_started` to create the subagent session. Called immediately + * after `_handleSubagentStarted`. + */ + private _drainPendingSubagentEvents(parentSession: ProtocolURI, parentToolCallId: string): void { + const subagentKey = `${parentSession}:${parentToolCallId}`; + const buffer = this._pendingSubagentEvents.get(subagentKey); + if (!buffer) { + return; + } + this._pendingSubagentEvents.delete(subagentKey); + this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered event(s) for subagent ${subagentKey}`); + for (const { event, agent, agentMapper } of buffer) { + this._handleAgentProgress(agent, agentMapper, event); + } } // ---- Subagent session management ---------------------------------------- @@ -478,6 +557,12 @@ export class AgentSideEffects extends Disposable { this._subagentSessions.delete(key); } } + // Drop any buffered events targeted at subagents that never started. + for (const key of [...this._pendingSubagentEvents.keys()]) { + if (key.startsWith(`${parentSession}:`)) { + this._pendingSubagentEvents.delete(key); + } + } } /** @@ -486,6 +571,13 @@ export class AgentSideEffects extends Disposable { */ completeSubagentSession(parentSession: ProtocolURI, toolCallId: string): void { const key = `${parentSession}:${toolCallId}`; + + // Drop any events that were buffered waiting for a `subagent_started` + // that never arrived (e.g. the parent tool failed before the subagent + // was created). Without this, the buffer entry would leak until the + // parent session is disposed. + this._pendingSubagentEvents.delete(key); + const subagentUri = this._subagentSessions.get(key); if (!subagentUri) { return; @@ -524,6 +616,13 @@ export class AgentSideEffects extends Disposable { for (const uri of this._stateManager.getSessionUrisWithPrefix(prefix)) { this._stateManager.removeSession(uri); } + + // Drop any buffered events targeted at subagents that never started. + for (const key of [...this._pendingSubagentEvents.keys()]) { + if (key.startsWith(`${parentSession}:`)) { + this._pendingSubagentEvents.delete(key); + } + } } /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index c6f30997b1e..3c96497d191 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -23,7 +23,7 @@ import type { IFileEdit, IToolDefinition } from '../../common/state/protocol/sta import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type IPendingMessage, type ISessionInputAnswer, type ISessionInputRequest, type IToolCallResult, type IToolResultContent } from '../../common/state/sessionState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import type { ShellManager } from './copilotShellTools.js'; -import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; +import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; import { FileEditTracker } from './fileEditTracker.js'; import { mapSessionEvents } from './mapSessionEvents.js'; import { buildPendingEditContentUri } from './pendingEditContentStore.js'; @@ -699,6 +699,7 @@ export class CopilotAgentSession extends Disposable { const displayName = getToolDisplayName(e.data.toolName); this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [] }); const toolKind = getToolKind(e.data.toolName); + const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; this._onDidSessionProgress.fire({ session, @@ -711,6 +712,8 @@ export class CopilotAgentSession extends Disposable { toolKind, language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined, toolArguments: toolArgs, + subagentAgentName: subagentMeta?.agentName, + subagentDescription: subagentMeta?.description, mcpServerName: e.data.mcpServerName, mcpToolName: e.data.mcpToolName, parentToolCallId: e.data.parentToolCallId, diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 962fe74b617..ef91cbe02bf 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -356,6 +356,28 @@ export function getToolKind(toolName: string): 'terminal' | 'subagent' | undefin return undefined; } +/** + * Extracts subagent metadata (agent name, description) from the parsed + * arguments of a Copilot SDK subagent tool call. The Copilot `task` tool + * uses `agent_type` (snake_case), which this normalizes into the generic + * `subagentAgentName` / `subagentDescription` shape used by the rest of the + * agent host code. + * + * Only call this for tools where {@link getToolKind} returned `'subagent'`. + */ +export function getSubagentMetadata(parameters: Record | undefined): { agentName?: string; description?: string } { + if (!parameters) { + return {}; + } + const agentName = typeof parameters.agent_type === 'string' && parameters.agent_type.length > 0 + ? parameters.agent_type + : undefined; + const description = typeof parameters.description === 'string' && parameters.description.length > 0 + ? parameters.description + : undefined; + return { agentName, description }; +} + /** * Returns the shell language identifier for syntax highlighting. * Used when creating terminal tool-specific data for the renderer. diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 903158d853c..bb8dc599b2e 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -7,7 +7,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IAgentMessageEvent, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { ToolResultContentType, type IToolResultContent } from '../../common/state/sessionState.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js'; +import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js'; import { buildSessionDbUri } from './fileEditTracker.js'; function tryStringify(value: unknown): string | undefined { @@ -163,6 +163,7 @@ export async function mapSessionEvents( const displayName = getToolDisplayName(d.toolName); const toolKind = getToolKind(d.toolName); const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; + const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(info?.parameters) : undefined; result.push({ session, type: 'tool_start', @@ -174,6 +175,8 @@ export async function mapSessionEvents( toolKind, language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined, toolArguments: toolArgs, + subagentAgentName: subagentMeta?.agentName, + subagentDescription: subagentMeta?.description, mcpServerName: d.mcpServerName, mcpToolName: d.mcpToolName, parentToolCallId: d.parentToolCallId, diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts index df39d40fd68..2e8055a13a2 100644 --- a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -400,7 +400,10 @@ suite('AgentEventMapper', () => { assert.strictEqual(readyAction.confirmed, undefined); }); - test('tool_start with subagent toolKind extracts agent metadata from toolArguments', () => { + test('tool_start with subagent metadata forwards it as `_meta`', () => { + // Per-SDK adapters (e.g. the Copilot adapter) extract subagent + // metadata from their tool argument shape and set it on the event. + // The generic mapper just forwards the fields into `_meta`. const event: IAgentToolStartEvent = { session, type: 'tool_start', @@ -409,7 +412,8 @@ suite('AgentEventMapper', () => { displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent', - toolArguments: JSON.stringify({ description: 'Review the code', agentName: 'code-reviewer' }), + subagentDescription: 'Review the code', + subagentAgentName: 'code-reviewer', }; const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index e738cea8fb7..a0ad42cb475 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -381,7 +381,7 @@ suite('AgentService (node dispatcher)', () => { copilotAgent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review this code', toolRequests: [] }, { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] }, - { type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent' as const, toolArguments: JSON.stringify({ description: 'Find related files', agentName: 'explore' }) }, + { type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent' as const, subagentDescription: 'Find related files', subagentAgentName: 'explore' }, { type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores the codebase' }, // Inner tool calls from the subagent (have parentToolCallId) { type: 'tool_start', session, toolCallId: 'tc-inner-1', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running ls...', parentToolCallId: 'tc-sub' }, diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 844033e1cde..524909a4891 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -18,7 +18,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; -import { PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; +import { buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; @@ -1370,6 +1370,51 @@ suite('AgentSideEffects', () => { assert.strictEqual(parentInnerTool, undefined, 'inner tool call should NOT be in parent session'); }); + test('completeSubagentSession clears pending buffered events when subagent never started', () => { + // Regression: if the parent tool completes (or fails) before any + // `subagent_started` arrives, buffered inner events would + // otherwise leak in `_pendingSubagentEvents` until session + // disposal. After completion, a late `subagent_started` for the + // same toolCallId must not replay stale events. + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', invocationMessage: 'Delegating...' }); + + // Inner event arrives but `subagent_started` never does. + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'inner-1', + toolName: 'read', + displayName: 'Read', + invocationMessage: 'Reading...', + parentToolCallId: 'tc-1', + }); + + // Parent tool completes (e.g. it errored before delegating). + agent.fireProgress({ + session: sessionUri, + type: 'tool_complete', + toolCallId: 'tc-1', + result: { success: false, pastTenseMessage: 'Failed' }, + }); + + // Now a late `subagent_started` for the same toolCallId arrives. + // This is unusual but possible after a reconnect/replay. The + // drain must NOT replay the (cleared) buffered inner tool call. + agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + + const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + const subState = stateManager.getSessionState(subagentUri); + assert.ok(subState, 'subagent session should still be created'); + const innerTool = subState!.activeTurn?.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1' + ); + assert.strictEqual(innerTool, undefined, 'stale buffered inner tool call must not be replayed'); + }); + test('completeSubagentSession completes the subagent turn when parent tool completes', () => { setupSession(); startTurn('turn-1'); @@ -1493,5 +1538,138 @@ suite('AgentSideEffects', () => { const textEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Text); assert.ok(textEntry, 'Completed tool should also have the SDK result content'); }); + + test('inner tool_start arriving BEFORE subagent_started routes to subagent (not parent)', () => { + // Reproduces the regression where inner subagent tool calls show up + // flat at the top level of the parent session because the SDK can + // emit `tool_start` (with parentToolCallId) before `subagent_started`. + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // 1. Parent tool starts (the `task` invocation). + agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...' }); + + // 2. Inner tool fires BEFORE subagent_started (race condition). + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'inner-tc-1', + toolName: 'readFile', + displayName: 'Read File', + invocationMessage: 'Reading file...', + parentToolCallId: 'tc-parent', + }); + + // 3. subagent_started arrives later. + agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + + const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent'); + const subState = stateManager.getSessionState(subagentUri); + assert.ok(subState?.activeTurn, 'subagent session should exist'); + + const innerTool = subState!.activeTurn!.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1' + ); + assert.ok(innerTool, 'inner tool fired before subagent_started should still end up in the subagent session'); + + // Parent must NOT have the inner tool. + const parentState = stateManager.getSessionState(sessionUri.toString()); + const parentInnerTool = parentState!.activeTurn!.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1' + ); + assert.strictEqual(parentInnerTool, undefined, 'inner tool must not leak into parent session'); + }); + + test('reads inside parent working directory are auto-approved for tools in subagent sessions', () => { + // Subagent sessions don't carry their own workingDirectory or + // autoApprove config. Without inheritance from the parent, every + // tool call inside a subagent (even a read in the workspace) would + // surface a confirmation dialog. + setupSession(URI.file('/workspace').toString()); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // Parent task tool spawns a subagent. + agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...' }); + agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + + // Inner tool inside the subagent requests permission to read a file + // inside the parent workspace. + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'inner-read-1', + toolName: 'read', + displayName: 'Read', + invocationMessage: 'Read file', + parentToolCallId: 'tc-parent', + }); + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'inner-read-1', + invocationMessage: 'Read src/app.ts', + permissionKind: 'read', + permissionPath: '/workspace/src/app.ts', + }); + + assert.deepStrictEqual(agent.respondToPermissionCalls, [ + { requestId: 'inner-read-1', approved: true }, + ]); + }); + + test('session-level autoApprove on the parent is inherited by tools in subagent sessions', () => { + setupSession(URI.file('/workspace').toString()); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // Set the parent session to "Bypass Approvals" via session config. + const parentState = stateManager.getSessionState(sessionUri.toString()); + if (parentState) { + parentState.config = { + schema: { + type: 'object', + properties: { + autoApprove: { + type: 'string', + title: 'Approvals', + enum: ['default', 'autoApprove', 'autopilot'], + default: 'default', + sessionMutable: true, + }, + }, + }, + values: { autoApprove: 'autoApprove' }, + }; + } + + agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...' }); + agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + + // Inner write outside the workspace would normally NOT auto-approve, + // but session-level autoApprove on the parent must apply. + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'inner-write-1', + toolName: 'write', + displayName: 'Write', + invocationMessage: 'Write file', + parentToolCallId: 'tc-parent', + }); + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'inner-write-1', + invocationMessage: 'Write /tmp/foo', + permissionKind: 'write', + permissionPath: '/tmp/foo', + }); + + assert.deepStrictEqual(agent.respondToPermissionCalls, [ + { requestId: 'inner-write-1', approved: true }, + ]); + }); }); }); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index c9a29587623..09bb5905612 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -515,6 +515,59 @@ export class ScriptedMockAgent implements IAgent { break; } + case 'subagent': { + // Spawns a subagent: parent `task` tool starts (emits start + + // auto-ready as a pair), then `subagent_started` creates the + // child session, then an inner tool runs in the child session + // (routed via `parentToolCallId`). + this._fireSequence(session, [ + { + type: 'tool_start', + session, + toolCallId: 'tc-task-1', + toolName: 'task', + displayName: 'Task', + invocationMessage: 'Spawning subagent', + toolKind: 'subagent', + subagentAgentName: 'explore', + subagentDescription: 'Explore', + }, + { + type: 'subagent_started', + session, + toolCallId: 'tc-task-1', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Exploration helper', + }, + { + type: 'tool_start', + session, + toolCallId: 'tc-inner-1', + toolName: 'echo_tool', + displayName: 'Echo Tool', + invocationMessage: 'Inner tool running...', + parentToolCallId: 'tc-task-1', + }, + { + type: 'tool_complete', + session, + toolCallId: 'tc-inner-1', + parentToolCallId: 'tc-task-1', + result: { pastTenseMessage: 'Ran inner tool', content: [{ type: ToolResultContentType.Text, text: 'inner-ok' }], success: true }, + }, + { + type: 'tool_complete', + session, + toolCallId: 'tc-task-1', + result: { pastTenseMessage: 'Subagent done', content: [{ type: ToolResultContentType.Text, text: 'task-ok' }], success: true }, + }, + { type: 'delta', session, messageId: 'msg-sa', content: 'Subagent finished.' }, + { type: 'idle', session }, + ]); + break; + } + default: this._fireSequence(session, [ { type: 'delta', session, messageId: 'msg-1', content: 'Unknown prompt: ' + prompt }, diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts index bc37aa6ce6f..09b88790312 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts @@ -23,13 +23,14 @@ import assert from 'assert'; import { execSync } from 'child_process'; -import { mkdtempSync, rmSync } from 'fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { removeAnsiEscapeCodes } from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; +import type { ISessionToolCallStartAction } from '../../../common/state/protocol/actions.js'; import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import { ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type ITerminalState, type IToolResultContent } from '../../../common/state/sessionState.js'; +import { ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, isSubagentSession, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type ITerminalState, type IToolResultContent, type IToolResultSubagentContent } from '../../../common/state/sessionState.js'; import type { ISessionAddedNotification, ISessionInputRequestedAction, ISessionResponsePartAction, ISessionToolCallReadyAction } from '../../../common/state/sessionActions.js'; import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; import { @@ -603,4 +604,111 @@ function terminalText(state: ITerminalState): string { const terminalState = terminalSnapshot.snapshot.state as ITerminalState; assert.ok(terminalText(terminalState).includes(resolvedWorkingDirectoryPath), `pwd output should include the resolved worktree path ${resolvedWorkingDirectoryPath}`); }); + + // ---- Subagent tool call grouping ---------------------------------------- + + test('subagent tool calls are routed to the subagent session, not flat in the parent', async function () { + this.timeout(180_000); + + // Set up a small fixture directory so the subagent has something to view. + const tempDir = mkdtempSync(`${tmpdir()}/ahp-subagent-test-`); + tempDirs.push(tempDir); + writeFileSync(`${tempDir}/file-a.txt`, 'alpha'); + writeFileSync(`${tempDir}/file-b.txt`, 'beta'); + + const sessionUri = await createRealSession(client, 'real-sdk-subagent', createdSessions, URI.file(tempDir).toString()); + + // Auto-approve every tool that needs confirmation while the turn runs. + // Multiple inner tool calls may need approval; doing this in a background + // loop keeps the turn unblocked. + let approvalsActive = true; + let approvalSeq = 1000; + const approvalLoop = (async () => { + while (approvalsActive) { + try { + const ready = await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady'), 2_000); + const action = getActionEnvelope(ready).action as { session: string; turnId: string; toolCallId: string; confirmed?: string }; + if (!action.confirmed) { + client.notify('dispatchAction', { + clientSeq: ++approvalSeq, + action: { + type: 'session/toolCallConfirmed', + session: action.session, + turnId: action.turnId, + toolCallId: action.toolCallId, + approved: true, + }, + }); + } + } catch { + // Timeout — re-poll. Loop exits when approvalsActive flips. + } + } + })(); + + // Encourage the model to delegate via the `task` subagent tool. The exact + // behaviour is non-deterministic — if the model declines we fail the test + // with a clear message rather than silently passing. + dispatchTurn(client, sessionUri, 'turn-sa', + 'Use the `task` tool to spawn a subagent to list the files in the current working directory. ' + + 'The subagent should call a single read-only tool (e.g. `view` or `bash` with `ls`) to enumerate the directory. ' + + 'Do not enumerate the directory yourself — delegate to the subagent.', + 1); + + // Wait for the parent's `task` tool call to expose a Subagent content + // block carrying the subagent session URI. + const subagentContentNotif = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/toolCallContentChanged')) { + return false; + } + const action = getActionEnvelope(n).action as { session: string; content: readonly IToolResultContent[] }; + return action.session === sessionUri && action.content.some(c => c.type === ToolResultContentType.Subagent); + }, 120_000); + + const parentContent = (getActionEnvelope(subagentContentNotif).action as { content: readonly IToolResultContent[] }).content; + const subagentRef = parentContent.find((c): c is IToolResultSubagentContent => c.type === ToolResultContentType.Subagent)!; + const subagentSessionUri = subagentRef.resource as unknown as string; + assert.ok(typeof subagentSessionUri === 'string' && isSubagentSession(subagentSessionUri), + `subagent session URI should be subagent-shaped, got: ${JSON.stringify(subagentSessionUri)}`); + + // Subscribe so we receive the subagent session's own action broadcasts. + await client.call('subscribe', { resource: subagentSessionUri }); + + // Wait for the parent turn to complete (with a generous timeout — the + // subagent's turn must finish first). + await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/turnComplete')) { + return false; + } + return (getActionEnvelope(n).action as { session: string }).session === sessionUri; + }, 150_000); + + approvalsActive = false; + await approvalLoop; + + // Group all received toolCallStart actions by the session they target. + // This is the bug's signature: when inner tool_start arrives before + // subagent_started, the inner tool calls leak into the parent session. + const toolStarts = client.receivedNotifications(n => isActionNotification(n, 'session/toolCallStart')) + .map(n => getActionEnvelope(n).action as ISessionToolCallStartAction); + + const parentStarts = toolStarts.filter(a => (a.session as unknown as string) === sessionUri); + const subagentStarts = toolStarts.filter(a => (a.session as unknown as string) === subagentSessionUri); + + // Parent should only carry the outer `task` tool call. Any other + // tool call on the parent indicates the inner-tool routing bug. + const parentNonTaskStarts = parentStarts.filter(a => a.toolName !== 'task'); + assert.deepStrictEqual( + parentNonTaskStarts.map(a => a.toolName), + [], + `parent session should not contain inner tool calls; found: ${JSON.stringify(parentNonTaskStarts.map(a => a.toolName))}`, + ); + + // Subagent session must have at least one inner tool call. If this + // fails, the subagent never actually executed any work — likely the + // model didn't delegate as instructed. + assert.ok(subagentStarts.length >= 1, + `subagent session should contain at least one inner tool call, got ${subagentStarts.length}. ` + + `Parent tool calls: ${JSON.stringify(parentStarts.map(a => a.toolName))}`); + }); }); diff --git a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts index ce67a79907a..36100c223ef 100644 --- a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; import type { IResponsePartAction } from '../../../common/state/sessionActions.js'; import type { IFetchTurnsResult } from '../../../common/state/sessionProtocol.js'; -import { ResponsePartKind, type IMarkdownResponsePart, type ISessionState } from '../../../common/state/sessionState.js'; +import { ResponsePartKind, buildSubagentSessionUri, type IMarkdownResponsePart, type ISessionState } from '../../../common/state/sessionState.js'; import { createAndSubscribeSession, dispatchTurnStarted, @@ -180,4 +180,36 @@ suite('Protocol WebSocket — Turn Execution', function () { const updatedModifiedAt = (updatedSnapshot.snapshot.state as ISessionState).summary.modifiedAt; assert.ok(updatedModifiedAt >= initialModifiedAt); }); + + test('subagent: inner tool calls land in child session, not parent', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-subagent'); + dispatchTurnStarted(client, sessionUri, 'turn-sa', 'subagent', 1); + + // Wait for the parent turn to complete. + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Subscribe to the child subagent session — its URI is derived from + // the parent session URI + parent toolCallId. + const childUri = buildSubagentSessionUri(sessionUri, 'tc-task-1'); + + const parentSnapshot = await client.call('subscribe', { resource: sessionUri }); + const parentState = parentSnapshot.snapshot.state as ISessionState; + const childSnapshot = await client.call('subscribe', { resource: childUri }); + const childState = childSnapshot.snapshot.state as ISessionState; + + // Parent turn should contain the `task` tool call but NOT the inner one. + const parentTurn = parentState.turns[parentState.turns.length - 1]; + const parentToolCalls = parentTurn.responseParts.filter(p => p.kind === ResponsePartKind.ToolCall); + const parentToolNames = parentToolCalls.map(p => p.toolCall.toolName); + assert.deepStrictEqual(parentToolNames, ['task'], 'parent turn should only contain the `task` tool call (inner tool must route to subagent)'); + + // Child session should have one turn with the inner `echo_tool` call. + assert.ok(childState.turns.length >= 1, 'child subagent session should have at least one turn'); + const childTurn = childState.turns[childState.turns.length - 1]; + const childToolCalls = childTurn.responseParts.filter(p => p.kind === ResponsePartKind.ToolCall); + const childToolNames = childToolCalls.map(p => p.toolCall.toolName); + assert.deepStrictEqual(childToolNames, ['echo_tool'], 'child subagent session should contain the inner `echo_tool` call'); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 73fa5961b06..7b85e685f78 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -21,7 +21,6 @@ import { ISessionTruncatedAction } from '../../../../../../platform/agentHost/co import { ICustomizationRef, TerminalClaimKind, ToolResultContentType, type IProtectedResourceMetadata, type IToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ISessionTurnStartedAction, type ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { AttachmentType, buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type IMessageAttachment, type IModelSelection, type IResponsePart, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; @@ -43,7 +42,7 @@ import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, To import { getAgentHostIcon } from '../agentSessions.js'; import { AgentHostEditingSession } from './agentHostEditingSession.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; -import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, makeAhpTerminalToolSessionId, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, type IToolCallFileEdit } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, type IToolCallFileEdit } from './stateToProgressAdapter.js'; // ============================================================================= // AgentHostSessionHandler - renderer-side handler for a single agent host @@ -1794,7 +1793,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (rp.kind === ResponsePartKind.ToolCall) { const tc = rp.toolCall; const existing = activeToolInvocations.get(tc.toolCallId); - if (existing && !observedSubagentToolIds.has(tc.toolCallId) && (getToolKind(tc) === 'subagent' || ((tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) && getToolSubagentContent(tc)))) { + if (existing && !observedSubagentToolIds.has(tc.toolCallId) && (isSubagentTool(tc) || ((tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) && getToolSubagentContent(tc)))) { observedSubagentToolIds.add(tc.toolCallId); this._observeSubagentSession(backendSession, tc.toolCallId, progress, disposables, observedSubagentToolIds); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 5fc1cbe6c97..4707e8d0486 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -60,15 +60,24 @@ function getSubagentAgentName(tc: { _meta?: Record }): string | /** * Known tool names that spawn subagent sessions. Used as a client-side - * fallback when the server hasn't set `_meta.toolKind` or subagent content - * (e.g. sessions restored by an older server version). + * fallback when the server hasn't set `_meta.toolKind` (e.g. sessions + * restored by an older server version that didn't carry `_meta`). */ const SUBAGENT_TOOL_NAMES: ReadonlySet = new Set(['task']); -function isSubagentToolName(toolName: string): boolean { +export function isSubagentToolName(toolName: string): boolean { return SUBAGENT_TOOL_NAMES.has(toolName); } +/** + * Returns true if this tool call spawns a subagent session, either because + * the server reported `_meta.toolKind === 'subagent'` or because the tool + * name is in the known fallback set (older snapshots without `_meta`). + */ +export function isSubagentTool(tc: IToolCallState): boolean { + return getToolKind(tc) === 'subagent' || isSubagentToolName(tc.toolName); +} + /** * Finds a terminal content block in a tool call's content array. * Returns the terminal URI if found. @@ -210,7 +219,7 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn // Check for subagent content const subagentContent = tc.status === ToolCallStatus.Completed ? getToolSubagentContent(tc) : undefined; - const isSubagent = subagentContent || getToolKind(tc) === 'subagent' || isSubagentToolName(tc.toolName); + const isSubagent = subagentContent || isSubagentTool(tc); if (isSubagent && tc.status === ToolCallStatus.Completed) { const resultText = getToolOutputText(tc); const pastTenseMsg = isSuccess @@ -470,7 +479,11 @@ export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocation }; if (tc.status === ToolCallStatus.PendingConfirmation) { - // Tool needs confirmation — create with confirmation messages + // Tool needs confirmation — create with confirmation messages. + // (Subagent-spawning tools never reach this state in production: the + // Copilot SDK's `task` tool doesn't request permission, and the event + // mapper auto-emits `tool_ready` with `confirmed: NotNeeded` paired + // with `tool_start`. So no special-case for subagents is needed here.) const confirmationMessages: IToolConfirmationMessages = { title: stringOrMarkdownToString(tc.confirmationTitle, connectionAuthority) ?? tc.displayName, message: stringOrMarkdownToString(tc.invocationMessage, connectionAuthority), @@ -542,27 +555,20 @@ export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocation terminalCommandUri: URI.parse(terminalContentUri), terminalCommandOutput: getTerminalOutput(tc), } satisfies IChatTerminalToolInvocationData; - } else if (getToolKind(tc) === 'subagent' || isSubagentToolName(tc.toolName)) { + } else if (isSubagentTool(tc)) { // Subagent-spawning tool: set subagent toolSpecificData eagerly so the - // renderer groups it correctly from the start (before content arrives). - // Agent metadata is extracted from tool arguments in the event mapper. - const metaDesc = tc._meta?.subagentDescription; - const metaAgent = tc._meta?.subagentAgentName; + // renderer groups it correctly from the start (before child content + // arrives). Agent metadata comes from `_meta` (set by the event + // mapper from the tool's arguments) and is later refined by the + // Subagent content block via `updateRunningToolSpecificData`. + const subagentContent = (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) + ? getToolSubagentContent(tc) + : undefined; invocation.toolSpecificData = { kind: 'subagent', - description: typeof metaDesc === 'string' ? metaDesc : undefined, - agentName: typeof metaAgent === 'string' ? metaAgent : undefined, + description: getSubagentTaskDescription(tc), + agentName: subagentContent?.agentName ?? getSubagentAgentName(tc), }; - } else if (tc.status === ToolCallStatus.Running) { - // Check for subagent content on initial creation (e.g. from snapshot) - const subagentContent = getToolSubagentContent(tc); - if (subagentContent) { - invocation.toolSpecificData = { - kind: 'subagent', - description: getSubagentTaskDescription(tc), - agentName: subagentContent.agentName, - }; - } } return invocation; @@ -592,6 +598,18 @@ export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: // toolSpecificData is a plain property — notify state observers // so ChatSubagentContentPart re-reads the updated metadata. existing.notifyToolSpecificDataChanged(); + return; + } + + // Refresh subagent metadata from `_meta` (set by the event mapper from + // the tool's arguments) in case it arrived after invocation creation. + if (existing.toolSpecificData?.kind === 'subagent') { + const description = getSubagentTaskDescription(tc) ?? existing.toolSpecificData.description; + const agentName = getSubagentAgentName(tc) ?? existing.toolSpecificData.agentName; + if (description !== existing.toolSpecificData.description || agentName !== existing.toolSpecificData.agentName) { + existing.toolSpecificData = { kind: 'subagent', description, agentName }; + existing.notifyToolSpecificDataChanged(); + } } } @@ -646,6 +664,15 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ITool agentName: subagentContent.agentName, result: resultText, }; + } else if (invocation.toolSpecificData?.kind === 'subagent') { + // Subagent-spawning tool that completed without a Subagent content + // block. Refresh metadata + carry the tool's output as the result. + invocation.toolSpecificData = { + kind: 'subagent', + description: getSubagentTaskDescription(tc) ?? invocation.toolSpecificData.description, + agentName: getSubagentAgentName(tc) ?? invocation.toolSpecificData.agentName, + result: getToolOutputText(tc), + }; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index ea877c2f857..ed4aceae374 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -746,16 +746,23 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } this.renderPromptSection(); this.updateTitle(); - } else if (this._isDefaultDescription && toolInvocation.toolSpecificData?.kind === 'subagent') { + } else if (toolInvocation.toolSpecificData?.kind === 'subagent') { // toolSpecificData was updated after initial render (e.g. - // subagent content arrived via SessionToolCallContentChanged). + // subagent content arrived via SessionToolCallContentChanged + // after the part was first constructed in PendingConfirmation). // Re-read metadata and update the title if real values are - // now available. + // now available that we didn't have before. const { description, isDefaultDescription, agentName } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); - if (!isDefaultDescription || agentName) { - this.description = description; - this._isDefaultDescription = isDefaultDescription; - this.agentName = agentName; + const descriptionChanged = this._isDefaultDescription && !isDefaultDescription; + const agentNameChanged = !!agentName && agentName !== this.agentName; + if (descriptionChanged || agentNameChanged) { + if (descriptionChanged) { + this.description = description; + this._isDefaultDescription = isDefaultDescription; + } + if (agentNameChanged) { + this.agentName = agentName; + } this.updateTitle(); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 80d14200b5b..f84672cfb47 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -19,7 +19,7 @@ import { AgentHostSessionConfigBranchNameHintKey, IAgentCreateSessionConfig, IAg import { isSessionAction, type IActionEnvelope, type INotification, type ISessionAction, type ITerminalAction, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { ICustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, ROOT_STATE_URI, PolicyState, ResponsePartKind, StateComponents, type ISessionState, type ISessionSummary, IRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, ROOT_STATE_URI, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, type ISessionState, type ISessionSummary, IRootState, type IToolCallState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; @@ -2545,4 +2545,206 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(ac.activeClient.customizations?.[0].uri, 'file:///plugin-b'); }); }); + + // ---- Subagent grouping ---------------------------------------------- + + suite('subagent grouping', () => { + + /** + * Build a child session state containing a single inner tool call in the running state. + */ + function makeChildState(childUri: string, innerToolCallId: string): ISessionState { + const summary: ISessionSummary = { + resource: childUri, + provider: 'copilot', + title: 'Subagent', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + const innerTool: IToolCallState = { + toolCallId: innerToolCallId, + toolName: 'read_file', + displayName: 'Read File', + status: ToolCallStatus.Running, + invocationMessage: 'Reading file', + toolInput: '{}', + confirmed: ToolCallConfirmationReason.NotNeeded, + } as IToolCallState; + const activeTurn = createActiveTurn('child-turn-1', { text: 'do work' }); + activeTurn.responseParts.push({ kind: ResponsePartKind.ToolCall, toolCall: innerTool }); + return { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + activeTurn, + }; + } + + test('inner subagent tool calls are forwarded with subAgentInvocationId set', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + + // Pre-populate the child subagent session state BEFORE the parent tool + // call fires, so that when the handler subscribes to it the inner tool + // is already present. + const parentToolCallId = 'tc-parent-task'; + const childSessionUri = buildSubagentSessionUri(session.toString(), parentToolCallId); + agentHostService.sessionStates.set(childSessionUri, makeChildState(childSessionUri, 'tc-child-1')); + + // Fire the parent task tool call with toolKind=subagent metadata. + fire({ + type: 'session/toolCallStart', session, turnId, + toolCallId: parentToolCallId, toolName: 'task', displayName: 'Task', + _meta: { toolKind: 'subagent', subagentDescription: 'do some work', subagentAgentName: 'helper' }, + } as ISessionAction); + fire({ + type: 'session/toolCallReady', session, turnId, + toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', + confirmed: 'not-needed', + } as ISessionAction); + + // Allow the throttler/observation flow to flush. + await timeout(50); + + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + // Flatten all progress emissions and find tool invocations. + const allParts = collected.flat(); + const toolInvocations = allParts.filter((p): p is IChatToolInvocation => p.kind === 'toolInvocation'); + + const parent = toolInvocations.find(t => t.toolCallId === parentToolCallId); + const child = toolInvocations.find(t => t.toolCallId === 'tc-child-1'); + + assert.ok(parent, 'parent task tool invocation should be emitted'); + assert.strictEqual(parent!.toolSpecificData?.kind, 'subagent', 'parent should have subagent toolSpecificData'); + assert.strictEqual(parent!.subAgentInvocationId, undefined, 'parent should not have a subAgentInvocationId'); + + assert.ok(child, 'inner child tool invocation should be forwarded into parent session progress'); + assert.strictEqual(child!.subAgentInvocationId, parentToolCallId, 'child should be tagged with parent tool call id for grouping'); + })); + + test('inner subagent tool calls fired AFTER parent observation are also grouped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + + const parentToolCallId = 'tc-parent-task'; + const childSessionUri = buildSubagentSessionUri(session.toString(), parentToolCallId); + + // Fire the parent task tool — this should cause the handler to subscribe + // to the (still-empty) child subagent session. + fire({ + type: 'session/toolCallStart', session, turnId, + toolCallId: parentToolCallId, toolName: 'task', displayName: 'Task', + _meta: { toolKind: 'subagent', subagentDescription: 'do work', subagentAgentName: 'helper' }, + } as ISessionAction); + fire({ + type: 'session/toolCallReady', session, turnId, + toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', + confirmed: 'not-needed', + } as ISessionAction); + + // Allow the subscription to be set up. + await timeout(50); + + // NOW fire the child session lifecycle: turnStarted, then a tool call. + const childTurnId = 'child-turn-1'; + const childToolCallId = 'tc-child-1'; + const fireChild = (action: ISessionAction) => { + agentHostService.fireAction({ action, serverSeq: 1000, origin: undefined }); + }; + fireChild({ + type: 'session/turnStarted', + session: childSessionUri, + turnId: childTurnId, + userMessage: { text: '' }, + } as ISessionAction); + fireChild({ + type: 'session/toolCallStart', session: childSessionUri, turnId: childTurnId, + toolCallId: childToolCallId, toolName: 'read_file', displayName: 'Read File', + } as ISessionAction); + fireChild({ + type: 'session/toolCallReady', session: childSessionUri, turnId: childTurnId, + toolCallId: childToolCallId, invocationMessage: 'Reading file', + confirmed: 'not-needed', + } as ISessionAction); + + await timeout(50); + + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + const allParts = collected.flat(); + const toolInvocations = allParts.filter((p): p is IChatToolInvocation => p.kind === 'toolInvocation'); + + const parent = toolInvocations.find(t => t.toolCallId === parentToolCallId); + const child = toolInvocations.find(t => t.toolCallId === childToolCallId); + + assert.ok(parent, 'parent task tool invocation should be emitted'); + assert.strictEqual(parent!.toolSpecificData?.kind, 'subagent'); + assert.strictEqual(parent!.subAgentInvocationId, undefined); + + assert.ok(child, 'child tool invocation fired after subscription should be forwarded'); + assert.strictEqual(child!.subAgentInvocationId, parentToolCallId, 'child should be tagged for grouping'); + })); + + test('parent subagent agentName is updated when subagent content arrives later', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // Repro for the missing-agent-name bug: when the parent task tool + // fires without `subagentAgentName` in `_meta` (e.g. the agent host + // did not extract it from args), the renderer should still pick up + // the agent name once the SDK emits a `subagent_started` event, + // which lands as a Subagent content block on the parent tool call. + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + + const parentToolCallId = 'tc-parent-task'; + + // Parent task tool fires WITHOUT subagentAgentName meta — only description. + fire({ + type: 'session/toolCallStart', session, turnId, + toolCallId: parentToolCallId, toolName: 'task', displayName: 'Task', + _meta: { toolKind: 'subagent', subagentDescription: 'Exploring codebase structure' }, + } as ISessionAction); + fire({ + type: 'session/toolCallReady', session, turnId, + toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', + confirmed: 'not-needed', + } as ISessionAction); + + await timeout(50); + + // Now the SDK emits subagent_started → handler dispatches a content + // change with a Subagent content block carrying the agent name. + fire({ + type: 'session/toolCallContentChanged', session, turnId, + toolCallId: parentToolCallId, + content: [{ + type: ToolResultContentType.Subagent, + resource: buildSubagentSessionUri(session.toString(), parentToolCallId), + title: 'Subagent', + agentName: 'explore', + }], + } as ISessionAction); + + await timeout(50); + + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + const allParts = collected.flat(); + const toolInvocations = allParts.filter((p): p is IChatToolInvocation => p.kind === 'toolInvocation'); + const parent = toolInvocations.find(t => t.toolCallId === parentToolCallId); + + assert.ok(parent, 'parent task tool invocation should be emitted'); + assert.strictEqual(parent!.toolSpecificData?.kind, 'subagent', 'parent should have subagent toolSpecificData'); + assert.strictEqual( + (parent!.toolSpecificData as { kind: 'subagent'; agentName?: string }).agentName, + 'explore', + 'parent toolSpecificData.agentName must be updated from the Subagent content block' + ); + })); + + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index e5d226a4ca3..0f97ee16be2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -353,6 +353,100 @@ suite('ChatSubagentContentPart', () => { }); }); + suite('Late metadata updates', () => { + // The parent subagent tool is often constructed before + // `subagent_started` (which carries the real agentName) arrives. + // The autorun in `watchToolCompletion` re-reads metadata when state + // changes and updates the title if the description transitioned from + // the default placeholder to a real value, or if the agentName + // changed to a real value. These tests cover that branch directly. + + function getTitleText(part: ChatSubagentContentPart): string { + const button = getCollapseButton(part); + assert.ok(button, 'Should have collapse button'); + const labelElement = getCollapseButtonLabel(button); + return labelElement?.textContent ?? button.textContent ?? ''; + } + + function getSettableState(toolInvocation: IChatToolInvocation): ReturnType> { + return toolInvocation.state as ReturnType>; + } + + function setToolSpecificData(toolInvocation: IChatToolInvocation, data: IChatSubagentToolInvocationData): void { + (toolInvocation as { toolSpecificData: IChatSubagentToolInvocationData }).toolSpecificData = data; + } + + test('default description with no agentName → real description arrives later → title updates', () => { + const toolInvocation = createMockToolInvocation({ + stateType: IChatToolInvocation.StateKind.WaitingForConfirmation, + toolSpecificData: { kind: 'subagent' /* no description, no agentName */ } + }); + const context = createMockRenderContext(false); + const part = createPart(toolInvocation, context); + + assert.ok(getTitleText(part).includes('Subagent:'), 'Title should start with default prefix'); + + // Late metadata: real description arrives via SessionToolCallContentChanged + setToolSpecificData(toolInvocation, { kind: 'subagent', description: 'Searching the codebase' }); + getSettableState(toolInvocation).set(createState(IChatToolInvocation.StateKind.Executing), undefined); + + assert.ok(getTitleText(part).includes('Searching the codebase'), 'Title should reflect the new description'); + }); + + test('real description already set → agentName arrives later → title updates (regression)', () => { + const toolInvocation = createMockToolInvocation({ + stateType: IChatToolInvocation.StateKind.WaitingForConfirmation, + toolSpecificData: { kind: 'subagent', description: 'Searching the codebase' /* no agentName */ } + }); + const context = createMockRenderContext(false); + const part = createPart(toolInvocation, context); + + assert.ok(getTitleText(part).includes('Searching the codebase'), 'Title should start with the real description'); + assert.ok(!getTitleText(part).includes('CodeSearchAgent'), 'Title should not yet have agent name'); + + // Late metadata: agentName arrives via subagent_started after the + // description has already been set (the bug we fixed). + setToolSpecificData(toolInvocation, { kind: 'subagent', description: 'Searching the codebase', agentName: 'CodeSearchAgent' }); + getSettableState(toolInvocation).set(createState(IChatToolInvocation.StateKind.Executing), undefined); + + assert.ok(getTitleText(part).includes('CodeSearchAgent'), 'Title should reflect the new agent name'); + }); + + test('agentName already set → empty agentName arrives → title NOT cleared', () => { + const toolInvocation = createMockToolInvocation({ + stateType: IChatToolInvocation.StateKind.WaitingForConfirmation, + toolSpecificData: { kind: 'subagent', description: 'Searching the codebase', agentName: 'CodeSearchAgent' } + }); + const context = createMockRenderContext(false); + const part = createPart(toolInvocation, context); + + assert.ok(getTitleText(part).includes('CodeSearchAgent'), 'Title should start with the agent name'); + + // A subsequent update arrives with no agentName field — the part + // must NOT clear the previously-set name. + setToolSpecificData(toolInvocation, { kind: 'subagent', description: 'Searching the codebase' }); + getSettableState(toolInvocation).set(createState(IChatToolInvocation.StateKind.Executing), undefined); + + assert.ok(getTitleText(part).includes('CodeSearchAgent'), 'Title should still have the agent name'); + }); + + test('real description already set → no further changes → title preserved', () => { + const toolInvocation = createMockToolInvocation({ + stateType: IChatToolInvocation.StateKind.WaitingForConfirmation, + toolSpecificData: { kind: 'subagent', description: 'Searching the codebase', agentName: 'CodeSearchAgent' } + }); + const context = createMockRenderContext(false); + const part = createPart(toolInvocation, context); + + const before = getTitleText(part); + + // Trigger the autorun without changing toolSpecificData. + getSettableState(toolInvocation).set(createState(IChatToolInvocation.StateKind.Executing), undefined); + + assert.strictEqual(getTitleText(part), before, 'Title should be unchanged when no metadata changed'); + }); + }); + suite('State management', () => { test('should start as active', () => { const toolInvocation = createMockToolInvocation(); From 0c5765295f23c7e43008118e417be440a6b31383 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 19 Apr 2026 14:06:00 -0700 Subject: [PATCH 010/114] Allow vscode-agent-host scheme in chat content markdown renderer (#311264) * Allow vscode-agent-host scheme in chat content markdown renderer The chat content markdown renderer's sanitizer didn't include the vscode-agent-host:// scheme in its allowed link schemes. After the recent change to rewrite remote agent host file links into empty-text markdown links (e.g. `[](vscode-agent-host://...)`), DOMPurify stripped the disallowed href, then rewriteRenderedLinks removed the now-empty entirely. As a result, renderFileWidgets had no anchor to convert into a clickable file widget, and tool past-tense messages like "Read [foo.ts](...)" rendered as raw text. Add AGENT_HOST_SCHEME to the augmented allowedLinkSchemes so the link survives sanitization and reaches renderFileWidgets. Tests: - markdownRenderer.test.ts: add coverage showing the default config strips disallowed-scheme links, and that augmenting allowedLinkSchemes preserves them with their data-href. - stateToProgressAdapter.test.ts: existing test confirms finalizeToolInvocation rewrites pastTenseMessage through the agent host scheme. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Copilot review: clarify test name (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/markdownRenderer.test.ts | 19 +++++++++++++++++ .../widget/chatContentMarkdownRenderer.ts | 3 ++- .../stateToProgressAdapter.test.ts | 21 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/vs/base/test/browser/markdownRenderer.test.ts b/src/vs/base/test/browser/markdownRenderer.test.ts index 16373be2101..2220d936bb0 100644 --- a/src/vs/base/test/browser/markdownRenderer.test.ts +++ b/src/vs/base/test/browser/markdownRenderer.test.ts @@ -33,6 +33,25 @@ suite('MarkdownRenderer', () => { const result: HTMLElement = store.add(renderMarkdown(markdown)).element; assert.strictEqual(result.innerHTML, '

image

'); }); + + test('Strips links with disallowed schemes (default config)', () => { + const markdown = { value: `Read [](vscode-agent-host://my-host/file/-/path/to/foo.ts)` }; + const result: HTMLElement = store.add(renderMarkdown(markdown)).element; + // No
element should remain because the scheme isn't allowed. + assert.strictEqual(result.querySelector('a'), null); + }); + + test('Preserves link when scheme is allowed via allowedLinkSchemes.augment', () => { + const markdown = { value: `Read [](vscode-agent-host://my-host/file/-/path/to/foo.ts)` }; + const result: HTMLElement = store.add(renderMarkdown(markdown, { + sanitizerConfig: { + allowedLinkSchemes: { augment: ['vscode-agent-host'] }, + }, + })).element; + const anchor = result.querySelector('a'); + assert.ok(anchor, 'expected to be preserved when scheme is allowed'); + assert.strictEqual(anchor!.dataset.href, 'vscode-agent-host://my-host/file/-/path/to/foo.ts'); + }); }); suite('Images', () => { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts index 4e035d1c320..ac9e9876907 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts @@ -15,6 +15,7 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import product from '../../../../../platform/product/common/product.js'; import { Schemas } from '../../../../../base/common/network.js'; +import { AGENT_HOST_SCHEME } from '../../../../../platform/agentHost/common/agentHostUri.js'; const _remoteImageDisallowed = () => false; @@ -81,7 +82,7 @@ export class ChatContentMarkdownRenderer implements IMarkdownRenderer { override: allowedChatMarkdownHtmlTags, }, ...options?.sanitizerConfig, - allowedLinkSchemes: { augment: [product.urlProtocol, 'copilot-skill', Schemas.vscodeBrowser] }, + allowedLinkSchemes: { augment: [product.urlProtocol, 'copilot-skill', Schemas.vscodeBrowser, AGENT_HOST_SCHEME] }, remoteImageIsAllowed: _remoteImageDisallowed, } }; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 620d3731445..d6a00b2371e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -397,6 +397,27 @@ suite('stateToProgressAdapter', () => { suite('finalizeToolInvocation', () => { + test('rewrites markdown links in pastTenseMessage through the agent host scheme', () => { + const tc = createToolCallState({ status: ToolCallStatus.Running }); + const invocation = toolCallStateToInvocation(tc); + + rawFinalizeToolInvocation(invocation, { + status: ToolCallStatus.Completed, + toolCallId: 'tc-1', + toolName: 'view_file', + displayName: 'View File', + invocationMessage: 'Reading file...', + confirmed: ToolCallConfirmationReason.NotNeeded, + success: true, + pastTenseMessage: { markdown: 'Read [foo.ts](file:///path/to/foo.ts)' }, + } as ICompletedToolCall, URI.file('/'), 'ssh__macbook-air'); + + assert.ok(invocation.pastTenseMessage); + assert.strictEqual(typeof invocation.pastTenseMessage, 'object'); + const value = (invocation.pastTenseMessage as { value: string }).value; + assert.strictEqual(value, 'Read [](vscode-agent-host://ssh__macbook-air/file/-/path/to/foo.ts)'); + }); + test('finalizes terminal tool with output and exit code', () => { const tc = createToolCallState({ toolInput: 'echo hi', From 96b10037d124291a558132f83dce7806de3f0561 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:35:03 -0700 Subject: [PATCH 011/114] better padding for reasoning when we have persistent progress (#311272) --- .../widget/chatContentParts/chatThinkingContentPart.ts | 5 +++++ .../widget/chatContentParts/media/chatThinkingContent.css | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 048bcd76e1f..05a004c9272 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -359,6 +359,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (this.fixedScrollingMode) { node.classList.add('chat-thinking-fixed-mode'); + if (!this.streamingCompleted && !this.element.isComplete && this.showProgressDetails) { + node.classList.add('chat-thinking-persistent-streaming'); + } this.currentTitle = this.defaultTitle; } @@ -946,6 +949,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.wrapper.classList.remove('chat-thinking-streaming'); } this.domNode.classList.remove('chat-thinking-active'); + this.domNode.classList.remove('chat-thinking-persistent-streaming'); this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); this.streamingCompleted = true; @@ -1352,6 +1356,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this.wrapper.classList.remove('chat-thinking-streaming'); } this.domNode.classList.remove('chat-thinking-active'); + this.domNode.classList.remove('chat-thinking-persistent-streaming'); this.streamingCompleted = true; if (this._collapseButton) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index 150b0c63886..47031cccd97 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -375,6 +375,10 @@ display: none; } + &.chat-thinking-persistent-streaming { + margin-bottom: 0; + } + > .monaco-scrollable-element > .shadow { display: none; } From a2ce40b82dc8b679cd1e98f12cd86d16ac2580e6 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:35:50 -0700 Subject: [PATCH 012/114] Use observables for the dropdowns in Claude (#311277) * Use observables for the dropdowns in Claude This makes it wayyyy easier to test as we can trigger the pipeline and see if the expected groups get created. Co-authored-by: Copilot * feedback --------- Co-authored-by: Copilot --- .../node/sessionParser/claudeSessionSchema.ts | 2 + .../node/sessionParser/sdkSessionAdapter.ts | 3 +- .../claudeChatSessionContentProvider.ts | 310 +++++++++++++----- .../vscode-node/claudeSessionOptionBuilder.ts | 60 ++-- .../claudeChatSessionContentProvider.spec.ts | 234 ++++++++++++- .../test/claudeSessionOptionBuilder.spec.ts | 35 +- 6 files changed, 506 insertions(+), 138 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts index 8035e48cbfc..7d3e1c3cd0b 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts @@ -517,6 +517,8 @@ export interface IClaudeCodeSessionInfo { readonly folderName?: string; /** Current working directory of the session */ readonly cwd?: string; + /** Git branch of the session */ + readonly gitBranch?: string; } // #endregion diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts index f074a576e8a..725d562fbc4 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts @@ -89,7 +89,8 @@ export function sdkSessionInfoToSessionInfo( created: info.createdAt ?? info.lastModified, lastRequestEnded: info.lastModified, folderName, - cwd: info.cwd + cwd: info.cwd, + gitBranch: info.gitBranch, }; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index 5293b7a177f..fe1430a79a7 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -5,13 +5,17 @@ import * as vscode from 'vscode'; import { ChatExtendedRequestHandler } from 'vscode'; +import { PermissionMode } from '@anthropic-ai/claude-agent-sdk'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { INativeEnvService } from '../../../platform/env/common/envService'; import { IGitService } from '../../../platform/git/common/gitService'; import { ILogService } from '../../../platform/log/common/logService'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; -import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { Emitter, Event } from '../../../util/vs/base/common/event'; +import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle'; +import { autorun, derived, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../util/vs/base/common/observable'; +import { basename } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo'; @@ -25,7 +29,8 @@ import { IClaudeCodeSessionInfo } from '../claude/node/sessionParser/claudeSessi import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService'; import { IChatFolderMruService } from '../common/folderRepositoryManager'; import { buildChatHistory } from './chatHistoryBuilder'; -import { ClaudeSessionOptionBuilder, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder'; +import { ClaudeSessionOptionBuilder, buildPermissionModeItems, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder'; +import { toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder'; // Import the tool permission handlers import '../claude/vscode-node/toolPermissionHandlers/index'; @@ -33,6 +38,14 @@ import '../claude/vscode-node/toolPermissionHandlers/index'; // Import the MCP server contributors to trigger self-registration import '../claude/vscode-node/mcpServers/index'; +interface InputStateReactivePipeline { + readonly permissionMode: ISettableObservable; + readonly folderUri: ISettableObservable; + readonly folderItems: ISettableObservable; + readonly isSessionStarted: ISettableObservable; + readonly store: DisposableStore; +} + export class ClaudeChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider { private readonly _controller: ClaudeChatSessionItemController; @@ -87,17 +100,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco // Lock the folder group when starting a new session (permission mode stays editable) if (isNewSession) { - const state = chatSessionContext.inputState; - state.groups = state.groups.map(group => { - if (group.id !== FOLDER_OPTION_ID) { - return group; - } - return { - ...group, - items: group.items.map(item => ({ ...item, locked: true })), - selected: group.selected ? { ...group.selected, locked: true } : undefined, - }; - }); + this._controller.markSessionStarted(chatSessionContext.inputState); } const modelId = parseClaudeModelId(request.model.id); @@ -176,10 +179,28 @@ export class ClaudeChatSessionItemController extends Disposable { private readonly _inProgressItems = new Map(); private _showBadge: boolean; + // #region Shared Observable State + + /** Whether the "bypass permissions" config is enabled — controls permission mode items. */ + private readonly _bypassPermissionsEnabled: IObservable; + + /** Current workspace folders — controls folder group items and visibility. */ + private readonly _workspaceFolders: IObservable; + + /** Disposes per-state autoruns when the state object is garbage collected. */ + private readonly _stateAutorunRegistry = new FinalizationRegistry( + store => store.dispose() + ); + + /** Maps input state objects to their reactive pipelines for external updates. */ + private readonly _statePipelines = new WeakMap(); + + // #endregion + constructor( @IClaudeCodeSessionService private readonly _claudeCodeSessionService: IClaudeCodeSessionService, @IClaudeSessionStateService private readonly _sessionStateService: IClaudeSessionStateService, - @IConfigurationService private readonly _configurationService: IConfigurationService, + @IConfigurationService _configurationService: IConfigurationService, @IChatFolderMruService folderMruService: IChatFolderMruService, @IWorkspaceService private readonly _workspaceService: IWorkspaceService, @INativeEnvService private readonly _envService: INativeEnvService, @@ -189,6 +210,24 @@ export class ClaudeChatSessionItemController extends Disposable { ) { super(); this._optionBuilder = new ClaudeSessionOptionBuilder(_configurationService, folderMruService, _workspaceService); + + this._bypassPermissionsEnabled = observableFromEvent( + this, + Event.filter(_configurationService.onDidChangeConfiguration, + e => e.affectsConfiguration(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions.fullyQualifiedId)), + () => _configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions) as boolean, + ); + + // Bridge vscode.Event → internal Event for workspace folder changes + const workspaceFoldersEmitter = this._register(new Emitter()); + const workspaceFoldersSubscription = _workspaceService.onDidChangeWorkspaceFolders(() => workspaceFoldersEmitter.fire()); + this._register({ dispose: () => workspaceFoldersSubscription.dispose() }); + this._workspaceFolders = observableFromEvent( + this, + workspaceFoldersEmitter.event, + () => _workspaceService.getWorkspaceFolders(), + ); + this._registerCommands(); this._controller = this._register(vscode.chat.createChatSessionItemController( ClaudeSessionUri.scheme, @@ -277,79 +316,206 @@ export class ClaudeChatSessionItemController extends Disposable { // #region Input State - private _setupInputState(): void { - const trackedStates: { ref: WeakRef }[] = []; + /** + * Creates a reactive pipeline for a single input state. + * + * Per-state observables (`permissionMode`, `folderUri`, `isSessionStarted`) are + * combined with shared observables (`_bypassPermissionsEnabled`, `_workspaceFolders`) + * into derived group computations. An autorun reads the derived groups and pushes + * the result to `state.groups`, which is the "UI". + * + * The `state` is only held weakly by the autoruns so it can be garbage-collected + * while the shared observables still reference the pipeline's observers. When the + * state is collected, the finalization registry disposes the store and unsubscribes. + * + * Returns the per-state observables so callers can drive external updates, plus a + * `DisposableStore` that owns the autorun lifecycle. + */ + private _createInputStateReactivePipeline( + state: vscode.ChatSessionInputState, + ): InputStateReactivePipeline { + const store = new DisposableStore(); - const sweepStaleEntries = () => { - for (let i = trackedStates.length - 1; i >= 0; i--) { - if (!trackedStates[i].ref.deref()) { - trackedStates.splice(i, 1); - } + // Seed values are computed up front so that the first autorun pass + // observes fully-seeded observables and does not clobber `initialGroups`. + const seed = this._computeSeedValues(state.groups); + + const permissionMode = observableValue(this, seed.permissionMode); + const folderUri = observableValue(this, seed.folderUri); + const folderItems = observableValue(this, seed.folderItems); + const isSessionStarted = observableValue(this, seed.isSessionStarted); + + // When workspace folders change, update folder items reactively. + // Falls back to the async MRU list when the workspace becomes empty, + // matching the old imperative `buildNewFolderGroup` behavior. + store.add(autorun(reader => { + /** @description syncWorkspaceFolderItems */ + const folders = this._workspaceFolders.read(reader); + if (folders.length !== 0) { + folderItems.set( + folders.map(f => toWorkspaceFolderOptionItem(f, this._workspaceService.getWorkspaceFolderName(f) || basename(f))), + undefined, + ); + } else { + this._optionBuilder.getFolderOptionItems() + .then(items => folderItems.set(items, undefined)) + .catch(e => this._logService.error(e)); } - }; + })); + const permissionModeGroup = derived(reader => { + /** @description permissionModeGroup */ + const bypassEnabled = this._bypassPermissionsEnabled.read(reader); + const selectedMode = permissionMode.read(reader); + const group = buildPermissionModeItems(bypassEnabled); + const selectedItem = group.items.find(i => i.id === selectedMode) ?? group.items[0]; + return { ...group, selected: selectedItem }; + }); + + const folderGroup = derived(reader => { + /** @description folderGroup */ + const items = folderItems.read(reader); + const folders = this._workspaceFolders.read(reader); + // Hide folder group when there's exactly one workspace folder (implicit) + if (folders.length === 1) { + return undefined; + } + const selectedFolder = folderUri.read(reader); + const locked = isSessionStarted.read(reader); + const lockedItems = locked ? items.map(i => ({ ...i, locked: true })) : items; + const selectedItem = selectedFolder + ? lockedItems.find(i => i.id === selectedFolder.fsPath) + : lockedItems[0]; + return { + id: FOLDER_OPTION_ID, + name: vscode.l10n.t('Folder'), + description: vscode.l10n.t('Pick Folder'), + items: lockedItems, + selected: selectedItem ? (locked ? { ...selectedItem, locked: true } : selectedItem) : undefined, + }; + }); + + const allGroups = derived(reader => { + /** @description allGroups */ + const groups: vscode.ChatSessionProviderOptionGroup[] = []; + const folder = folderGroup.read(reader); + if (folder) { + groups.push(folder); + } + groups.push(permissionModeGroup.read(reader)); + return groups; + }); + + // Hold `state` via a WeakRef so the autorun's closure does not retain it. + // Shared observables (`_workspaceFolders`, `_bypassPermissionsEnabled`) hold + // strong references to autoruns; without the WeakRef, `state` would transitively + // stay reachable forever and `_stateAutorunRegistry` could never fire. + const stateRef = new WeakRef(state); + store.add(autorun(reader => { + /** @description syncInputStateGroups */ + const groups = allGroups.read(reader); + const currentState = stateRef.deref(); + if (currentState) { + currentState.groups = groups; + } + })); + + return { permissionMode, folderUri, folderItems, isSessionStarted, store }; + } + + private _setupInputState(): void { this._controller.getChatSessionInputState = async (sessionResource, context, token) => { if (context.previousInputState) { const state = this._controller.createChatSessionInputState([...context.previousInputState.groups]); - trackedStates.push({ ref: new WeakRef(state) }); + const pipeline = this._createInputStateReactivePipeline(state); + this._statePipelines.set(state, pipeline); + this._stateAutorunRegistry.register(state, pipeline.store); return state; } const isExistingSession = sessionResource && await this._claudeCodeSessionService.getSession(sessionResource, token) !== undefined; - - const groups = isExistingSession + const initialGroups = isExistingSession ? await this._buildExistingSessionGroups(sessionResource) : await this._optionBuilder.buildNewSessionGroups(); - const state = this._controller.createChatSessionInputState(groups); - trackedStates.push({ ref: new WeakRef(state) }); + const state = this._controller.createChatSessionInputState(initialGroups); + const pipeline = this._createInputStateReactivePipeline(state); + + if (isExistingSession) { + pipeline.isSessionStarted.set(true, undefined); + } + + // React to external permission mode changes for this session + if (sessionResource) { + const sessionId = ClaudeSessionUri.getSessionId(sessionResource); + const externalPermissionMode = observableFromEvent( + this, + Event.filter(this._sessionStateService.onDidChangeSessionState, + e => e.sessionId === sessionId && e.permissionMode !== undefined), + () => this._sessionStateService.getPermissionModeForSession(sessionId), + ); + pipeline.store.add(autorun(reader => { + /** @description syncExternalPermissionMode */ + pipeline.permissionMode.set(externalPermissionMode.read(reader), undefined); + })); + } + + this._statePipelines.set(state, pipeline); + this._stateAutorunRegistry.register(state, pipeline.store); return state; }; + } - // Rebuild active input states when external conditions change - const refreshActiveInputStates = () => { - sweepStaleEntries(); - for (const entry of trackedStates) { - const state = entry.ref.deref(); - if (state) { - this._rebuildInputState(state).catch(e => this._logService.error(e)); - } - } - }; + /** + * Extracts seed values for the per-state observables from the input groups. + * Pure and synchronous — runs before any autoruns are attached so the first + * autorun pass observes fully-seeded values and does not overwrite the + * carefully-constructed initial groups. + * + * Also recovers the `isSessionStarted` signal from `locked` items — required to + * preserve lock state when restoring a previously-started session. + */ + private _computeSeedValues(groups: readonly vscode.ChatSessionProviderOptionGroup[]): { + readonly permissionMode: PermissionMode; + readonly folderUri: URI | undefined; + readonly folderItems: readonly vscode.ChatSessionProviderOptionItem[]; + readonly isSessionStarted: boolean; + } { + let permissionMode: PermissionMode = this._optionBuilder.lastUsedPermissionMode; + const permissionGroup = groups.find(g => g.id === PERMISSION_MODE_OPTION_ID); + if (permissionGroup?.selected && isPermissionMode(permissionGroup.selected.id)) { + permissionMode = permissionGroup.selected.id; + } - // Config change (bypass permissions toggle) → may add/remove permission items - this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions.fullyQualifiedId)) { - refreshActiveInputStates(); + let folderUri: URI | undefined; + let folderItems: readonly vscode.ChatSessionProviderOptionItem[] = []; + let isSessionStarted = false; + const folderGroup = groups.find(g => g.id === FOLDER_OPTION_ID); + if (folderGroup) { + if (folderGroup.items.length > 0) { + folderItems = folderGroup.items; } - })); + if (folderGroup.selected) { + folderUri = URI.file(folderGroup.selected.id); + } + // Restore the "started" signal: if any items (or the selected item) carry + // `locked: true`, the session was previously started and must stay locked. + if (folderGroup.selected?.locked || folderGroup.items.some(i => i.locked)) { + isSessionStarted = true; + } + } - // Workspace folder changes → may add/remove folder group - this._register(this._workspaceService.onDidChangeWorkspaceFolders(() => { - refreshActiveInputStates(); - })); + return { permissionMode, folderUri, folderItems, isSessionStarted }; + } - // Session state service changes (e.g., permission mode changed externally) - this._register(this._sessionStateService.onDidChangeSessionState(e => { - if (e.permissionMode === undefined) { - return; - } - for (const entry of trackedStates) { - const state = entry.ref.deref(); - if (state?.sessionResource) { - const stateSessionId = ClaudeSessionUri.getSessionId(state.sessionResource); - if (stateSessionId === e.sessionId) { - const permissionGroup = this._optionBuilder.buildPermissionModeGroup(); - const selectedItem = permissionGroup.items.find(i => i.id === e.permissionMode); - if (selectedItem) { - const updatedGroup = { ...permissionGroup, selected: selectedItem }; - state.groups = state.groups.map(g => - g.id === PERMISSION_MODE_OPTION_ID ? updatedGroup : g - ); - } - } - } - } - })); + /** + * Marks the input state as "started", which locks the folder group. + * Called by the content provider when a new session begins. + */ + markSessionStarted(inputState: vscode.ChatSessionInputState): void { + const pipeline = this._statePipelines.get(inputState); + if (pipeline) { + pipeline.isSessionStarted.set(true, undefined); + } } private async _buildExistingSessionGroups(sessionResource: vscode.Uri): Promise { @@ -374,14 +540,6 @@ export class ClaudeChatSessionItemController extends Disposable { return this._optionBuilder.buildExistingSessionGroups(permissionMode, folderUri); } - private async _rebuildInputState(state: vscode.ChatSessionInputState): Promise { - if (state.sessionResource) { - state.groups = await this._buildExistingSessionGroups(state.sessionResource); - } else { - state.groups = await this._optionBuilder.buildNewSessionGroups(state); - } - } - // #endregion // #region Folder Resolution diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts index 4f579a59372..87b5d2d250c 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts @@ -42,18 +42,16 @@ export class ClaudeSessionOptionBuilder { private readonly _workspaceService: IWorkspaceService, ) { } - async buildNewSessionGroups(previousInputState?: vscode.ChatSessionInputState): Promise { + async buildNewSessionGroups(): Promise { const groups: vscode.ChatSessionProviderOptionGroup[] = []; - const folderGroup = await this.buildNewFolderGroup(previousInputState); + const folderGroup = await this.buildNewFolderGroup(); if (folderGroup) { groups.push(folderGroup); } const permissionGroup = this.buildPermissionModeGroup(); - const previousPermission = previousInputState ? getSelectedOption(previousInputState.groups, PERMISSION_MODE_OPTION_ID) : undefined; - const selectedPermissionId = previousPermission?.id ?? this._lastUsedPermissionMode; - const selectedPermission = permissionGroup.items.find(i => i.id === selectedPermissionId); + const selectedPermission = permissionGroup.items.find(i => i.id === this._lastUsedPermissionMode); groups.push({ ...permissionGroup, selected: selectedPermission ?? permissionGroup.items[0], @@ -80,40 +78,23 @@ export class ClaudeSessionOptionBuilder { } buildPermissionModeGroup(): vscode.ChatSessionProviderOptionGroup { - const items: vscode.ChatSessionProviderOptionItem[] = [ - { id: 'default', name: l10n.t('Ask before edits') }, - { id: 'acceptEdits', name: l10n.t('Edit automatically') }, - { id: 'plan', name: l10n.t('Plan mode') }, - ]; - - if (this._configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions)) { - items.push({ id: 'bypassPermissions', name: l10n.t('Bypass all permissions') }); - } - - return { - id: PERMISSION_MODE_OPTION_ID, - name: l10n.t('Permission Mode'), - description: l10n.t('Pick Permission Mode'), - items, - }; + const bypassEnabled = this._configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions); + return buildPermissionModeItems(bypassEnabled); } - async buildNewFolderGroup(previousInputState: vscode.ChatSessionInputState | undefined): Promise { + async buildNewFolderGroup(): Promise { const workspaceFolders = this._workspaceService.getWorkspaceFolders(); if (workspaceFolders.length === 1) { return undefined; } const folderItems = await this.getFolderOptionItems(); - const previousFolder = previousInputState ? getSelectedOption(previousInputState.groups, FOLDER_OPTION_ID) : undefined; - const defaultFolderId = previousFolder?.id ?? folderItems[0]?.id; - const selectedItem = defaultFolderId ? folderItems.find(i => i.id === defaultFolderId) : undefined; return { id: FOLDER_OPTION_ID, name: l10n.t('Folder'), description: l10n.t('Pick Folder'), items: folderItems, - selected: selectedItem ?? folderItems[0], + selected: folderItems[0], }; } @@ -176,3 +157,30 @@ export class ClaudeSessionOptionBuilder { return { permissionMode, folderUri }; } } + +// #region Pure group-building functions (observable-friendly) + +/** + * Build the permission mode option group from explicit inputs. + * Pure and synchronous — suitable for use in `derived` computations. + */ +export function buildPermissionModeItems(bypassEnabled: boolean): vscode.ChatSessionProviderOptionGroup { + const items: vscode.ChatSessionProviderOptionItem[] = [ + { id: 'default', name: l10n.t('Ask before edits') }, + { id: 'acceptEdits', name: l10n.t('Edit automatically') }, + { id: 'plan', name: l10n.t('Plan mode') }, + ]; + + if (bypassEnabled) { + items.push({ id: 'bypassPermissions', name: l10n.t('Bypass all permissions') }); + } + + return { + id: PERMISSION_MODE_OPTION_ID, + name: l10n.t('Permission Mode'), + description: l10n.t('Pick Permission Mode'), + items, + }; +} + +// #endregion diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index 180746f260c..6b24f11ea52 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import type * as vscode from 'vscode'; // eslint-disable-next-line no-duplicate-imports import * as vscodeShim from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { IGitService, RepoContext } from '../../../../platform/git/common/gitService'; import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService'; import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; @@ -202,15 +203,38 @@ function buildInputStateGroups(options?: { permissionMode?: string; folderPath?: return groups; } +/** + * Workspace service whose folder list can be mutated at runtime so tests can + * exercise folder-change events through the observable pipeline. + */ +class MutableWorkspaceService extends TestWorkspaceService { + private _folders: URI[]; + + constructor(folders: URI[]) { + super(folders); + this._folders = [...folders]; + } + + override getWorkspaceFolders(): URI[] { + return this._folders; + } + + setFolders(folders: URI[]): void { + this._folders = [...folders]; + this.didChangeWorkspaceFoldersEmitter.fire({ added: [], removed: [] } as any); + } +} + function createProviderWithServices( store: DisposableStore, workspaceFolders: URI[], mocks: ReturnType, agentManager?: ClaudeAgentManager, + workspaceServiceOverride?: TestWorkspaceService, ): { provider: ClaudeChatSessionContentProvider; accessor: ITestingServicesAccessor } { const serviceCollection = store.add(createExtensionUnitTestingServices(store)); - const workspaceService = new TestWorkspaceService(workspaceFolders); + const workspaceService = workspaceServiceOverride ?? new TestWorkspaceService(workspaceFolders); serviceCollection.set(IWorkspaceService, workspaceService); serviceCollection.set(IGitService, new MockGitService()); @@ -965,6 +989,190 @@ describe('ChatSessionContentProvider', () => { }); // #endregion + + // #region Observable pipeline reactivity + + /** + * These tests drive the input-state observable pipeline end-to-end via the + * external signals it observes (config change, workspace folder change, + * session-state change, session start) and assert the resulting + * `state.groups` reflect each event. This is the "series of events" testing + * the observable refactor was designed to enable. + */ + describe('observable pipeline reactivity', () => { + const folderA = URI.file('/project-a'); + const folderB = URI.file('/project-b'); + + async function flushMicrotasks(): Promise { + // Autoruns that schedule async work (e.g. MRU fetch when workspace goes empty) + // settle on the microtask queue. Two ticks covers chained thenables. + await Promise.resolve(); + await Promise.resolve(); + } + + it('toggling bypass-permissions config adds/removes the bypass item reactively', async () => { + const mocks = createDefaultMocks(); + const { accessor: localAccessor } = createProviderWithServices(store, [folderA, folderB], mocks); + const configService = localAccessor.get(IConfigurationService); + + const state = await getInputState(); + let permissionGroup = getGroup(state, 'permissionMode')!; + expect(permissionGroup.items.map(i => i.id)).not.toContain('bypassPermissions'); + + await configService.setConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions, true); + permissionGroup = getGroup(state, 'permissionMode')!; + expect(permissionGroup.items.map(i => i.id)).toContain('bypassPermissions'); + + await configService.setConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions, false); + permissionGroup = getGroup(state, 'permissionMode')!; + expect(permissionGroup.items.map(i => i.id)).not.toContain('bypassPermissions'); + }); + + it('workspace folder changes reshape the folder group', async () => { + const mocks = createDefaultMocks(); + const mutableWs = new MutableWorkspaceService([folderA, folderB]); + createProviderWithServices(store, [], mocks, undefined, mutableWs); + + const state = await getInputState(); + let folderGroup = getGroup(state, 'folder'); + expect(folderGroup).toBeDefined(); + expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]); + + // Add a third folder + const folderC = URI.file('/project-c'); + mutableWs.setFolders([folderA, folderB, folderC]); + folderGroup = getGroup(state, 'folder'); + expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath, folderC.fsPath]); + + // Transition to a single folder → group hides + mutableWs.setFolders([folderA]); + folderGroup = getGroup(state, 'folder'); + expect(folderGroup).toBeUndefined(); + + // Back to multi-root + mutableWs.setFolders([folderA, folderB]); + folderGroup = getGroup(state, 'folder'); + expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]); + }); + + it('emptying the workspace falls back to MRU items', async () => { + const mocks = createDefaultMocks(); + const mutableWs = new MutableWorkspaceService([folderA, folderB]); + const mruFolder = URI.file('/recent/project'); + mocks.mockFolderMruService.setMRUEntries([ + { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, + ]); + createProviderWithServices(store, [], mocks, undefined, mutableWs); + + const state = await getInputState(); + mutableWs.setFolders([]); + await flushMicrotasks(); + + const folderGroup = getGroup(state, 'folder'); + expect(folderGroup).toBeDefined(); + expect(folderGroup!.items.map(i => i.id)).toEqual([mruFolder.fsPath]); + }); + + it('external session-state permission change syncs into the input state', async () => { + const mocks = createDefaultMocks(); + const { accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks); + const sessionStateService = localAccessor.get(IClaudeSessionStateService); + + // Mark as existing so the pipeline wires up the external permission autorun + const existingSession = { id: 'external-session', messages: [], subagents: [] }; + vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(existingSession as any); + + const sessionUri = createClaudeSessionUri('external-session'); + const state = await getInputState(sessionUri); + expect(getGroup(state, 'permissionMode')!.selected?.id).not.toBe('plan'); + + sessionStateService.setPermissionModeForSession('external-session', 'plan'); + expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('plan'); + + sessionStateService.setPermissionModeForSession('external-session', 'default'); + expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('default'); + }); + + it('markSessionStarted locks the folder group mid-session', async () => { + const mocks = createDefaultMocks(); + createProviderWithServices(store, [folderA, folderB], mocks); + + const state = await getInputState(); + let folderGroup = getGroup(state, 'folder')!; + expect(folderGroup.items.every(i => !i.locked)).toBe(true); + expect(folderGroup.selected?.locked).toBeUndefined(); + + // Simulate a new session starting by invoking the handler (which calls markSessionStarted) + // The handler is owned by the content provider — we go through it via createHandler. + // Easier: reach through via the exported accessor pattern — call markSessionStarted through the controller. + // The content provider does not export the controller, but the handler path covers it. + vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(undefined); + seedSessionItem('new-session'); + + const { provider: handlerProvider } = createProviderWithServices(store, [folderA, folderB], mocks); + const handler = handlerProvider.createHandler(); + // The state we want to observe must be the one passed into the handler + const newState = await getInputState(); + const context: vscode.ChatContext = { + history: [], + yieldRequested: false, + chatSessionContext: { + isUntitled: false, + chatSessionItem: { + resource: ClaudeSessionUri.forSessionId('new-session'), + label: 'New', + }, + inputState: newState, + }, + } as vscode.ChatContext; + await handler(createTestRequest('hello'), context, new MockChatResponseStream(), CancellationToken.None); + + folderGroup = getGroup(newState, 'folder')!; + expect(folderGroup.items.every(i => i.locked === true)).toBe(true); + expect(folderGroup.selected?.locked).toBe(true); + }); + + it('restoring a locked previousInputState preserves the lock across workspace changes', async () => { + const mocks = createDefaultMocks(); + const mutableWs = new MutableWorkspaceService([folderA, folderB]); + createProviderWithServices(store, [], mocks, undefined, mutableWs); + + // First state — mark it as started to get locked items + const initialState = await getInputState(); + const initialGroup = getGroup(initialState, 'folder')!; + // Synthesize a locked previousInputState (matching what a started session looks like) + const lockedGroups: vscode.ChatSessionProviderOptionGroup[] = initialState.groups.map(g => + g.id === 'folder' + ? { + ...g, + items: g.items.map(i => ({ ...i, locked: true })), + selected: g.selected ? { ...g.selected, locked: true } : undefined, + } + : g + ); + const lockedPrevious: vscode.ChatSessionInputState = { + groups: lockedGroups, + sessionResource: undefined, + onDidChange: Event.None, + }; + // sanity check + expect(initialGroup.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]); + + // Restore from the locked previous state + const restoredState = await getInputState(undefined, lockedPrevious); + let restoredGroup = getGroup(restoredState, 'folder')!; + expect(restoredGroup.items.every(i => i.locked === true)).toBe(true); + + // Now workspace folders change — lock must persist + const folderC = URI.file('/project-c'); + mutableWs.setFolders([folderA, folderB, folderC]); + restoredGroup = getGroup(restoredState, 'folder')!; + expect(restoredGroup.items).toHaveLength(3); + expect(restoredGroup.items.every(i => i.locked === true)).toBe(true); + }); + }); + + // #endregion }); // #region FakeGitService @@ -1006,6 +1214,7 @@ describe('ClaudeChatSessionItemController', () => { let mockSessionService: IClaudeCodeSessionService; let mockSdkService: IClaudeCodeSdkService; let controller: ClaudeChatSessionItemController; + let lastControllerAccessor: ITestingServicesAccessor; function getItem(sessionId: string): vscode.ChatSessionItem | undefined { return lastCreatedItemsMap.get(ClaudeSessionUri.forSessionId(sessionId).toString()); @@ -1031,6 +1240,7 @@ describe('ClaudeChatSessionItemController', () => { }; serviceCollection.define(IClaudeCodeSdkService, mockSdkService); const accessor = serviceCollection.createTestingAccessor(); + lastControllerAccessor = accessor; const ctrl = accessor.get(IInstantiationService).createInstance(ClaudeChatSessionItemController); store.add(ctrl); return ctrl; @@ -1504,12 +1714,24 @@ describe('ClaudeChatSessionItemController', () => { label: 'Original', }); - const result = await lastForkHandler!(sessionResource, undefined, CancellationToken.None); + // Seed the parent session with non-default state + const sessionStateService = lastControllerAccessor.get(IClaudeSessionStateService); + sessionStateService.setPermissionModeForSession('sess-1', 'plan'); + sessionStateService.setFolderInfoForSession('sess-1', { + cwd: '/custom/folder', + additionalDirectories: ['/extra'], + }); - // The forked item should be properly structured - expect(result.resource.toString()).toContain('forked-session-id'); - expect(result.iconPath).toBeDefined(); - expect(result.timing).toBeDefined(); + const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession'); + const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession'); + + await lastForkHandler!(sessionResource, undefined, CancellationToken.None); + + expect(setPermissionSpy).toHaveBeenCalledWith('forked-session-id', 'plan'); + expect(setFolderInfoSpy).toHaveBeenCalledWith('forked-session-id', { + cwd: '/custom/folder', + additionalDirectories: ['/extra'], + }); }); it('forks at the message before the specified request', async () => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts index 8621db5e8ab..a163f78a0e5 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts @@ -8,7 +8,6 @@ import type * as vscode from 'vscode'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; -import { Event } from '../../../../util/vs/base/common/event'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; import { URI } from '../../../../util/vs/base/common/uri'; import { createExtensionUnitTestingServices } from '../../../test/node/services'; @@ -76,7 +75,7 @@ describe('ClaudeSessionOptionBuilder', () => { it('returns undefined for single-root workspace', async () => { builder = createBuilder([URI.file('/project')]); - const group = await builder.buildNewFolderGroup(undefined); + const group = await builder.buildNewFolderGroup(); expect(group).toBeUndefined(); }); @@ -86,7 +85,7 @@ describe('ClaudeSessionOptionBuilder', () => { const folderB = URI.file('/b'); builder = createBuilder([folderA, folderB]); - const group = await builder.buildNewFolderGroup(undefined); + const group = await builder.buildNewFolderGroup(); expect(group).toBeDefined(); expect(group!.id).toBe('folder'); @@ -101,33 +100,11 @@ describe('ClaudeSessionOptionBuilder', () => { { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, ]); - const group = await builder.buildNewFolderGroup(undefined); + const group = await builder.buildNewFolderGroup(); expect(group).toBeDefined(); expect(group!.items[0].id).toBe(mruFolder.fsPath); }); - - it('restores previous folder selection', async () => { - const folderA = URI.file('/a'); - const folderB = URI.file('/b'); - builder = createBuilder([folderA, folderB]); - - const previousInputState = { - groups: [{ - id: 'folder', - name: 'Folder', - description: 'Pick Folder', - items: [{ id: folderB.fsPath, name: 'b' }], - selected: { id: folderB.fsPath, name: 'b' }, - }], - sessionResource: undefined, - onDidChange: Event.None, - } as vscode.ChatSessionInputState; - - const group = await builder.buildNewFolderGroup(previousInputState); - - expect(group!.selected?.id).toBe(folderB.fsPath); - }); }); describe('buildExistingFolderGroup', () => { @@ -147,7 +124,7 @@ describe('ClaudeSessionOptionBuilder', () => { it('includes permission mode group with default selection', async () => { builder = createBuilder([URI.file('/project')]); - const groups = await builder.buildNewSessionGroups(undefined); + const groups = await builder.buildNewSessionGroups(); const permGroup = groups.find(g => g.id === 'permissionMode'); expect(permGroup).toBeDefined(); @@ -157,7 +134,7 @@ describe('ClaudeSessionOptionBuilder', () => { it('excludes folder group for single-root workspace', async () => { builder = createBuilder([URI.file('/project')]); - const groups = await builder.buildNewSessionGroups(undefined); + const groups = await builder.buildNewSessionGroups(); expect(groups.find(g => g.id === 'folder')).toBeUndefined(); }); @@ -165,7 +142,7 @@ describe('ClaudeSessionOptionBuilder', () => { it('includes folder group for multi-root workspace', async () => { builder = createBuilder([URI.file('/a'), URI.file('/b')]); - const groups = await builder.buildNewSessionGroups(undefined); + const groups = await builder.buildNewSessionGroups(); expect(groups.find(g => g.id === 'folder')).toBeDefined(); }); From c8249a6ec09b9988153d8cc786524fb088096f17 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Sun, 19 Apr 2026 17:58:12 -0700 Subject: [PATCH 013/114] instructions: enhance touch & iOS compatibility guidelines for Agents window (#311281) --- .github/instructions/sessions.instructions.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/instructions/sessions.instructions.md b/.github/instructions/sessions.instructions.md index ef9dd0066c7..6dec29dbb1c 100644 --- a/.github/instructions/sessions.instructions.md +++ b/.github/instructions/sessions.instructions.md @@ -11,3 +11,10 @@ When working on files under `src/vs/sessions/`, use these skills for detailed gu - **`sessions`** skill — covers the full architecture: layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines - **`agent-sessions-layout`** skill — covers the fixed layout structure, grid configuration, part visibility, editor modal, titlebar, sidebar footer, and implementation requirements + +## Touch & iOS Compatibility + +The Agents window can run on touch-capable platforms (notably iOS). Follow these rules for all DOM interaction code: + +- Do not use `EventType.MOUSE_DOWN`, `EventType.MOUSE_UP`, or `EventType.MOUSE_MOVE` with `addDisposableListener` directly — on iOS, these events don't fire because the platform uses pointer events. Use `addDisposableGenericMouseDownListener`, `addDisposableGenericMouseUpListener`, or `addDisposableGenericMouseMoveListener` instead, which automatically select the correct event type per platform. +- Add `touch-action: manipulation` in CSS on custom clickable elements (e.g. picker triggers, title bar pills, or other `
`/`` elements styled as buttons) to eliminate the 300ms tap delay on touch devices. This is not needed for native `