From 2289e09115974aeaa92411d6df4b7a2ca631cf76 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Apr 2026 17:25:51 -0700 Subject: [PATCH 1/2] agentHost: finish implementing host-level settings --- .../browser/remoteAgentHostProtocolClient.ts | 6 +- .../agentHost/common/agentHostSchema.ts | 112 ++-- .../platform/agentHost/common/agentService.ts | 6 +- .../common/state/agentSubscription.ts | 2 +- .../electron-browser/agentHostService.ts | 6 +- .../agentHost/node/agentHostStateManager.ts | 17 +- .../platform/agentHost/node/agentService.ts | 4 +- .../agentHost/node/copilot/copilotAgent.ts | 7 +- .../agentHost/node/protocolServerHandler.ts | 4 +- .../agentHost/node/sessionPermissions.ts | 8 +- .../test/node/agentHostStateManager.test.ts | 10 +- .../test/node/protocolServerHandler.test.ts | 6 +- .../common/agentHostSessionsProvider.ts | 22 + .../browser/agentHostSettings.contribution.ts | 74 +++ .../agentHostSettingsFileSystemProvider.ts | 157 ++++++ .../browser/agentHostSettingsShared.ts | 512 ++++++++++++++++++ .../agentSessionSettingsFileSystemProvider.ts | 506 ++++------------- .../browser/baseAgentHostSessionsProvider.ts | 93 +++- .../browser/localAgentHostSessionsProvider.ts | 2 + .../localAgentHostSessionsProvider.test.ts | 8 +- .../browser/sessionWorkspacePicker.test.ts | 6 +- .../remoteAgentHostSessionsProvider.ts | 2 + .../remoteAgentHostSessionsProvider.test.ts | 8 +- src/vs/sessions/sessions.desktop.main.ts | 1 + src/vs/sessions/sessions.web.main.ts | 1 + .../agentHost/loggingAgentConnection.ts | 4 +- .../agentHostChatContribution.test.ts | 8 +- .../agentHostClientTools.test.ts | 6 +- .../test/browser/agentHostPty.test.ts | 6 +- 29 files changed, 1114 insertions(+), 490 deletions(-) create mode 100644 src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts create mode 100644 src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts create mode 100644 src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index bf286e095d4a6..4ddc4975a3a74 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -20,7 +20,7 @@ import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentResolv import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js'; import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, type RootState } from '../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; @@ -182,7 +182,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return this._subscriptionManager.getSubscriptionUnmanaged(resource); } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { const seq = this._subscriptionManager.dispatchOptimistic(action); this.dispatchAction(action, this._clientId, seq); } @@ -205,7 +205,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * Dispatch a client action to the server. Returns the clientSeq used. */ - dispatchAction(action: SessionAction | TerminalAction, _clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, _clientId: string, clientSeq: number): void { this._sendNotification('dispatchAction', { clientSeq, action }); } diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 9a7dcfd63b235..88da94471d6be 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -95,18 +95,29 @@ export interface ISchema { */ assertValid(key: K, value: unknown): asserts value is SchemaValue; /** - * Returns a fully-typed values bag by validating each key of - * `defaults` against `values` and falling back to the default when + * Returns a fully-typed values bag by validating each key of the + * schema against `values` and falling back to the default when * the incoming value is missing or fails validation. * + * Semantics: for every key declared in the schema `definition`: + * - if `values[key]` validates, it is kept; + * - else if `key` is present in `defaults`, the default is used; + * - else the key is omitted from the result. + * + * This means callers MAY supply defaults for only a subset of the + * schema — keys not present in `defaults` are simply left unset + * when the incoming value is missing or invalid. This is useful + * when some properties (e.g. per-session `permissions`) should be + * inherited from a higher scope rather than materialized on every + * new session. + * * Intended for sanitizing untrusted input at protocol boundaries - * (e.g. `resolveSessionConfig`), where callers want a complete - * type-safe object rather than a throw-on-first-error response. - * Keys that fail validation are silently replaced with their - * default; use {@link values} or {@link assertValid} when you want - * a descriptive {@link ProtocolError} instead. + * (e.g. `resolveSessionConfig`). Keys that fail validation are + * silently replaced with their default or dropped; use + * {@link values} or {@link assertValid} when you want a descriptive + * {@link ProtocolError} instead. */ - validateOrDefault }>(values: Record | undefined, defaults: T): T; + validateOrDefault }>>(values: Record | undefined, defaults: T): Record; } export function createSchema(definition: D): ISchema { @@ -147,16 +158,21 @@ export function createSchema(definition: D): ISchema const narrowed: ISchemaProperty = prop; narrowed.assertValid(value, key); }, - validateOrDefault }>(values: Record | undefined, defaults: T): T { + validateOrDefault }>>(values: Record | undefined, defaults: T): Record { const result: Record = {}; - for (const key of Object.keys(defaults)) { - const raw = values?.[key]; + const raw = values ?? {}; + for (const key of Object.keys(definition)) { const prop = definition[key]; - result[key] = prop && raw !== undefined && prop.validate(raw) - ? raw - : (defaults as Record)[key]; + const candidate = raw[key]; + if (candidate !== undefined && prop.validate(candidate)) { + result[key] = candidate; + } else if (Object.prototype.hasOwnProperty.call(defaults, key)) { + result[key] = (defaults as Record)[key]; + } + // else: key not in defaults and incoming value missing/invalid + // → leave unset so higher-scope defaults can fill in. } - return result as T; + return result; }, }; } @@ -249,6 +265,32 @@ export interface IPermissionsValue { readonly deny: readonly string[]; } +const permissionsProperty = schemaProperty({ + type: 'object', + title: localize('agentHost.sessionConfig.permissions', "Permissions"), + description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."), + properties: { + allow: { + type: 'array', + title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"), + items: { + type: 'string', + title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), + }, + }, + deny: { + type: 'array', + title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"), + items: { + type: 'string', + title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), + }, + }, + }, + default: { allow: [], deny: [] }, + sessionMutable: true, +}); + /** * Session-config properties owned by the platform itself — i.e. consumed * by the agent host rather than by any particular agent. @@ -276,29 +318,19 @@ export const platformSessionSchema = createSchema({ default: 'default', sessionMutable: true, }), - [SessionConfigKey.Permissions]: schemaProperty({ - type: 'object', - title: localize('agentHost.sessionConfig.permissions', "Permissions"), - description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."), - properties: { - allow: { - type: 'array', - title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"), - items: { - type: 'string', - title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), - }, - }, - deny: { - type: 'array', - title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"), - items: { - type: 'string', - title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), - }, - }, - }, - default: { allow: [], deny: [] }, - sessionMutable: true, - }), + [SessionConfigKey.Permissions]: permissionsProperty, +}); + +/** + * Root (agent host) config properties owned by the platform itself. + * + * Root config acts as the baseline that applies to every session: + * + * - {@link SessionConfigKey.Permissions} — host-wide allow/deny lists + * unioned with each session's own permissions when evaluating tool + * auto-approval. See `SessionPermissionManager` for the evaluation + * rules. + */ +export const platformRootSchema = createSchema({ + [SessionConfigKey.Permissions]: permissionsProperty, }); diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 66005d03aa40a..032e7abc62dc6 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -13,7 +13,7 @@ import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import { ProtectedResourceMetadata, type ConfigSchema, type FileEdit, type ModelSelection, type SessionActiveClient, type ToolDefinition } from './state/protocol/state.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from './state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type CustomizationRef, type PendingMessage, type RootState, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type PolicyState, type StringOrMarkdown } from './state/sessionState.js'; @@ -599,7 +599,7 @@ export interface IAgentService { * it to state, triggers side effects, and echoes it back via * {@link onDidAction} with the client's origin for reconciliation. */ - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void; + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void; /** * List the contents of a directory on the agent host's filesystem. @@ -652,7 +652,7 @@ export interface IAgentConnection { getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined; // ---- Action dispatch ---------------------------------------------------- - dispatch(action: SessionAction | TerminalAction): void; + dispatch(action: RootAction | SessionAction | TerminalAction): void; // ---- Events (connection-level) ------------------------------------------ readonly onDidNotification: Event; diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index c36b92cd96320..26fd1e5be5e22 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -453,7 +453,7 @@ export class AgentSubscriptionManager extends Disposable { * Dispatch a client action. Applies optimistically to the relevant * subscription if applicable, then returns the clientSeq. */ - dispatchOptimistic(action: SessionAction | TerminalAction): number { + dispatchOptimistic(action: RootAction | SessionAction | TerminalAction): number { if (isSessionAction(action)) { const entry = this._subscriptions.get(URI.parse(action.session)); if (entry && entry.sub instanceof SessionStateSubscription) { diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index 543e9c374d441..78c65c73f6772 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -17,7 +17,7 @@ import { ILogService } from '../../log/common/log.js'; import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { StateComponents, ROOT_STATE_URI, type RootState } from '../common/state/sessionState.js'; import { revive } from '../../../base/common/marshalling.js'; @@ -160,7 +160,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { unsubscribe(resource: URI): void { this._proxy.unsubscribe(resource); } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this._proxy.dispatchAction(action, clientId, clientSeq); } private _nextSeq = 1; @@ -180,7 +180,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { return this._subscriptionManager.getSubscriptionUnmanaged(resource); } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { const seq = this._subscriptionManager.dispatchOptimistic(action); this.dispatchAction(action, this.clientId, seq); } diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 92b5fb624c762..9bb890e45f401 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -11,6 +11,8 @@ import { ActionType, NotificationType, ActionEnvelope, ActionOrigin, INotificati import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, SessionLifecycle, type RootState, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js'; +import { IPermissionsValue, platformRootSchema } from '../common/agentHostSchema.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; /** * Server-side state manager for the sessions process protocol. @@ -46,6 +48,19 @@ export class AgentHostStateManager extends Disposable { ) { super(); this._rootState = createRootState(); + // Seed the host-level configuration schema + default values so that + // RootConfigChanged actions can merge into it, and clients see the + // schema immediately upon subscribing to `agenthost:/root`. See + // `platformRootSchema` for the set of platform-owned properties. + this._rootState = { + ...this._rootState, + config: { + schema: platformRootSchema.toProtocol(), + values: platformRootSchema.validateOrDefault({}, { + [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue, + }), + }, + }; } private readonly _log = (msg: string) => this._logService.warn(`[AgentHostStateManager] ${msg}`); @@ -228,7 +243,7 @@ export class AgentHostStateManager extends Disposable { * The action is applied to state and emitted with the client's origin * so the originating client can reconcile. */ - dispatchClientAction(action: SessionAction | TerminalAction, origin: ActionOrigin): unknown { + dispatchClientAction(action: RootAction | SessionAction | TerminalAction, origin: ActionOrigin): unknown { return this._applyAndEmit(action, origin); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c6a4983c21afa..abf84f27c3cd5 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -16,7 +16,7 @@ import { ServiceCollection } from '../../instantiation/common/serviceCollection. import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; -import { ActionType, ActionEnvelope, INotification, SessionAction, TerminalAction, isSessionAction } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction, isSessionAction } from '../common/state/sessionActions.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseSubagentSessionUri, type ResponsePart, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolCallCompletedState, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; @@ -415,7 +415,7 @@ export class AgentService extends Disposable implements IAgentService { // in Phase 4 (multi-client). For now this is a no-op. } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); const origin = { clientId, clientSeq }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 7b2fdad114812..de102fdedde7f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -24,7 +24,7 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { ILogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; -import { AutoApproveLevel, IPermissionsValue, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import { AutoApproveLevel, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; @@ -620,7 +620,10 @@ export class CopilotAgent extends Disposable implements IAgent { const values = sessionSchema.validateOrDefault(params.config, { [SessionConfigKey.Isolation]: isolationValue, [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel, - [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue, + // Permissions intentionally omitted — leave unset so auto-approval + // falls through to the host-level `permissions` default, and only + // materializes on the session once the user hits "Allow in this + // Session". ...(branchDefault !== undefined ? { [SessionConfigKey.Branch]: branchDefault } : {}), }); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index d01dd2de427d5..b0467bfb5c74a 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -12,7 +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 { CommandMap } from '../common/state/protocol/messages.js'; -import { ActionEnvelope, INotification, isSessionAction, isTerminalAction, type SessionAction } from '../common/state/sessionActions.js'; +import { ActionEnvelope, INotification, isSessionAction, isTerminalAction, type RootAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { AHP_AUTH_REQUIRED, @@ -181,7 +181,7 @@ export class ProtocolServerHandler extends Disposable { case 'dispatchAction': if (client) { this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(msg.params.action.type)}`); - const action = msg.params.action as SessionAction; + const action = msg.params.action as RootAction | SessionAction | TerminalAction; this._agentService.dispatchAction(action, client.clientId, msg.params.clientSeq); } break; diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index eaf18711995dd..a1955bf63d49d 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -11,8 +11,7 @@ import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import type { IAgentToolReadyEvent } from '../common/agentService.js'; import { platformSessionSchema } from '../common/agentHostSchema.js'; -import { SessionConfigKey } from '../common/sessionConfigKeys.js'; -import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js'; import { ResponsePartKind, @@ -238,10 +237,13 @@ export class SessionPermissionManager extends Disposable { if (!toolName) { return false; } + // `getEffectiveValue` walks session → parent → host, so sessions + // that haven't materialized their own `permissions` yet transparently + // inherit from the host-level allow/deny lists. const permissions = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Permissions); const allowed = permissions?.allow.includes(toolName) ?? false; if (allowed) { - this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via session permissions`); + this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via permissions`); } return allowed; } diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index cf5d8aab4c183..cdbdd9b7505d9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -61,7 +61,11 @@ suite('AgentHostStateManager', () => { const snapshot = manager.getSnapshot(ROOT_STATE_URI); assert.ok(snapshot); assert.strictEqual(snapshot.resource.toString(), ROOT_STATE_URI.toString()); - assert.deepStrictEqual(snapshot.state, { agents: [], activeSessions: 0 }); + const root = snapshot.state as { agents: unknown[]; activeSessions: number; config?: { values?: Record } }; + assert.deepStrictEqual(root.agents, []); + assert.strictEqual(root.activeSessions, 0); + // Host config is seeded with the platform root schema and defaults. + assert.ok(root.config, 'root state should include a seeded config'); }); test('getSnapshot returns session snapshot after creation', () => { @@ -180,7 +184,9 @@ suite('AgentHostStateManager', () => { test('root state starts with activeSessions: 0', () => { const snapshot = manager.getSnapshot(ROOT_STATE_URI); assert.ok(snapshot); - assert.deepStrictEqual(snapshot.state, { agents: [], activeSessions: 0 }); + const root = snapshot.state as { agents: unknown[]; activeSessions: number }; + assert.deepStrictEqual(root.agents, []); + assert.strictEqual(root.activeSessions, 0); }); test('turnStarted dispatches root/activeSessionsChanged with correct count', () => { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 191e01f71539e..95e80e6df76b5 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; import { ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; +import { ActionType, type RootAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { SessionStatus, type SessionSummary } from '../../common/state/sessionState.js'; @@ -68,7 +68,7 @@ class MockProtocolServer implements IProtocolServer { class MockAgentService implements IAgentService { declare readonly _serviceBrand: undefined; - readonly handledActions: SessionAction[] = []; + readonly handledActions: (RootAction | SessionAction | TerminalAction)[] = []; readonly browsedUris: URI[] = []; readonly browseErrors = new Map(); readonly listedSessions: IAgentSessionMetadata[] = []; @@ -86,7 +86,7 @@ class MockAgentService implements IAgentService { this._stateManager = sm; } - dispatchAction(action: SessionAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.handledActions.push(action); const origin = { clientId, clientSeq }; this._stateManager.dispatchClientAction(action, origin); diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index b1bdfe7fef46c..c1382b3b248f1 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -8,6 +8,7 @@ import { IObservable } from '../../base/common/observable.js'; import { equals } from '../../base/common/objects.js'; import { RemoteAgentHostConnectionStatus } from '../../platform/agentHost/common/remoteAgentHostService.js'; import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platform/agentHost/common/state/protocol/commands.js'; +import { RootConfigState } from '../../platform/agentHost/common/state/protocol/state.js'; import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; /** @@ -66,6 +67,27 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { getCreateSessionConfig(sessionId: string): Record | undefined; /** Clears dynamic configuration state for an abandoned new session. */ clearSessionConfig(sessionId: string): void; + + // -- Root (agent host) Config -- + + /** Fires when the root (agent host) configuration schema or values change. */ + readonly onDidChangeRootConfig: Event; + /** Returns the last-known root (agent host) configuration, or `undefined` if the host has not published any. */ + getRootConfig(): RootConfigState | undefined; + /** + * Sets one root configuration property. + * + * Optimistically updates local state and dispatches a + * `root/configChanged` action (non-replace) to the agent host. + */ + setRootConfigValue(property: string, value: unknown): Promise; + /** + * Replaces the full set of root configuration values atomically. + * + * Dispatches a single `root/configChanged` action with replace semantics. + * Unknown keys (no schema entry) are ignored. + */ + replaceRootConfig(values: Record): Promise; } export const LOCAL_AGENT_HOST_PROVIDER_ID = 'local-agent-host'; diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts new file mode 100644 index 0000000000000..6c863a4281ba0 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { ChatSessionProviderIdContext } from '../../../common/contextkeys.js'; +import { ISession } from '../../../services/sessions/common/session.js'; +import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; +import { agentHostSettingsUri, AGENT_HOST_SETTINGS_SCHEME, AgentHostSettingsFileSystemProvider, AgentHostSettingsSchemaRegistrar } from './agentHostSettingsFileSystemProvider.js'; +import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; + +/** + * Registers the {@link AgentHostSettingsFileSystemProvider} with the + * {@link IFileService} and contributes the "Open Host Settings" action. + */ +class AgentHostSettingsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.agentHostSettingsContribution'; + + constructor( + @IFileService fileService: IFileService, + @IInstantiationService instantiationService: IInstantiationService, + @ILabelService labelService: ILabelService, + ) { + super(); + + const schemaRegistrar = this._register(instantiationService.createInstance(AgentHostSettingsSchemaRegistrar)); + const provider = this._register(instantiationService.createInstance(AgentHostSettingsFileSystemProvider, schemaRegistrar)); + this._register(fileService.registerProvider(AGENT_HOST_SETTINGS_SCHEME, provider)); + + this._register(labelService.registerFormatter({ + scheme: AGENT_HOST_SETTINGS_SCHEME, + formatting: { + label: localize('agentHostSettings.label', "Host Settings"), + separator: '/', + }, + })); + } +} + +registerWorkbenchContribution2(AgentHostSettingsContribution.ID, AgentHostSettingsContribution, WorkbenchPhase.AfterRestored); + +registerAction2(class OpenHostSettingsAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.openHostSettings', + title: localize2('openHostSettings', "Open Host Settings"), + menu: [{ + id: SessionItemContextMenuId, + group: '2_settings', + order: 2, + when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), + }] + }); + } + async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { + const session = Array.isArray(context) ? context[0] : context; + if (!session) { + return; + } + const editorService = accessor.get(IEditorService); + const resource = agentHostSettingsUri(session.providerId); + await editorService.openEditor({ resource, options: { pinned: true } }); + } +}); diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts new file mode 100644 index 0000000000000..75127ddc4622d --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts @@ -0,0 +1,157 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { RootConfigState } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { + AbstractAgentHostConfigFileSystemProvider, + AbstractAgentHostConfigSchemaRegistrar, + AgentHostConfigPropertyFilter, + buildAgentHostConfigJsonSchema, + IAgentHostConfigLike, + IAgentHostSettingsContext, + IAgentHostSettingsLocale, + serializeAgentHostConfigDocument, +} from './agentHostSettingsShared.js'; + +/** Scheme for the synthetic agent-host settings files. */ +export const AGENT_HOST_SETTINGS_SCHEME = 'agent-host-settings'; + +/** + * Build the URI used to open the settings file for an agent host provider. + * + * URI shape: `agent-host-settings://{providerId}/settings.jsonc` + */ +export function agentHostSettingsUri(providerId: string): URI { + return URI.from({ + scheme: AGENT_HOST_SETTINGS_SCHEME, + authority: providerId, + path: `/settings.jsonc`, + }); +} + +function parseHostSettingsUri(uri: URI): IAgentHostSettingsContext | undefined { + if (uri.scheme !== AGENT_HOST_SETTINGS_SCHEME) { + return undefined; + } + const providerId = uri.authority; + if (!providerId) { + return undefined; + } + return { providerId }; +} + +/** Root (agent host) config exposes no per-property mutability flags — all props are editable. */ +const hostSettingsPropertyFilter: AgentHostConfigPropertyFilter = () => true; + +const hostSettingsLocale: IAgentHostSettingsLocale = { + get header() { return localize('agentHostSettings.header', "Agent host settings."); }, + get saveHint() { return localize('agentHostSettings.saveHint', "Edit values below and save to apply. Unknown properties are ignored."); }, + get parseError() { return localize('agentHostSettings.parseError', "Failed to parse agent host settings as JSON."); }, + get notObject() { return localize('agentHostSettings.notObject', "Agent host settings must be a JSON object."); }, +}; + +/** + * Serialize the root config values for an agent host provider into a + * commented, pretty-printed JSON document. + */ +export function serializeHostSettings(provider: IAgentHostSessionsProvider): string { + return serializeAgentHostConfigDocument(provider.getRootConfig(), hostSettingsPropertyFilter, hostSettingsLocale); +} + +/** + * Build a JSON schema describing the root config of an agent host provider. + */ +export function buildHostSettingsJsonSchema(config: RootConfigState): IJSONSchema { + return buildAgentHostConfigJsonSchema(config, hostSettingsPropertyFilter); +} + +/** + * Filesystem provider serving synthetic JSONC documents representing the + * root (agent host) configuration values of agent-host providers. + */ +export class AgentHostSettingsFileSystemProvider extends AbstractAgentHostConfigFileSystemProvider { + + protected readonly _schemeLabel = AGENT_HOST_SETTINGS_SCHEME; + protected readonly _traceTag = 'AgentHostSettings'; + protected readonly _locale = hostSettingsLocale; + + constructor( + private readonly _schemaRegistrar: AgentHostSettingsSchemaRegistrar, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ILogService logService: ILogService, + ) { + super(sessionsProvidersService, logService); + } + + protected _parseUri(resource: URI): IAgentHostSettingsContext | undefined { + return parseHostSettingsUri(resource); + } + + protected _serialize(provider: IAgentHostSessionsProvider): string { + return serializeHostSettings(provider); + } + + protected _watchChanges(provider: IAgentHostSessionsProvider, _ctx: IAgentHostSettingsContext, fire: () => void): IDisposable { + return provider.onDidChangeRootConfig(() => fire()); + } + + protected _ensureSchemaRegistered(provider: IAgentHostSessionsProvider): void { + this._schemaRegistrar.ensureRegistered(provider, provider); + } + + protected _hasConfig(provider: IAgentHostSessionsProvider): boolean { + return provider.getRootConfig() !== undefined; + } + + protected _replaceConfig(provider: IAgentHostSessionsProvider, _ctx: IAgentHostSettingsContext, values: Record): Promise { + return provider.replaceRootConfig(values); + } + + protected _describeForTrace(ctx: IAgentHostSettingsContext): string { + return `provider ${ctx.providerId}`; + } +} + +/** + * Keeps per-provider JSON schemas registered so editors of the synthetic + * `agent-host-settings://…` files get completions, hover, and validation. + */ +export class AgentHostSettingsSchemaRegistrar extends AbstractAgentHostConfigSchemaRegistrar { + + protected _propertyFilter(): AgentHostConfigPropertyFilter { + return hostSettingsPropertyFilter; + } + + protected _settingsUri(provider: IAgentHostSessionsProvider): string { + return agentHostSettingsUri(provider.id).toString(); + } + + protected _schemaId(provider: IAgentHostSessionsProvider): string { + return `vscode://schemas/agent-host-settings/${provider.id}.jsonc`; + } + + protected _getConfig(_provider: IAgentHostSessionsProvider, target: IAgentHostSessionsProvider): IAgentHostConfigLike | undefined { + return target.getRootConfig(); + } + + protected _targetsForProvider(provider: IAgentHostSessionsProvider): readonly IAgentHostSessionsProvider[] { + return [provider]; + } + + protected _observeProvider( + provider: IAgentHostSessionsProvider, + onChanged: (target: IAgentHostSessionsProvider) => void, + _onRemoved: (target: IAgentHostSessionsProvider) => void, + ): IDisposable { + return provider.onDidChangeRootConfig(() => onChanged(provider)); + } +} diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts new file mode 100644 index 0000000000000..e29f5b904d136 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts @@ -0,0 +1,512 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { parse, ParseError } from '../../../../base/common/json.js'; +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { + createFileSystemProviderError, + FileChangeType, + FilePermission, + FileSystemProviderCapabilities, + FileSystemProviderErrorCode, + FileType, + IFileChange, + IFileDeleteOptions, + IFileOverwriteOptions, + IFileSystemProviderWithFileReadWriteCapability, + IFileWriteOptions, + IStat, + IWatchOptions, +} from '../../../../platform/files/common/files.js'; +import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { ConfigPropertySchema, ConfigSchema } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; + +// ============================================================================ +// Shared helpers for agent-host config settings filesystem providers. +// +// Both the per-session (`agent-session-settings://...`) and the per-host +// (`agent-host-settings://...`) synthetic settings editors follow the same +// shape: they render a provider's config schema as a JSONC document, watch +// for config changes, and round-trip user edits through a +// `replace*Config` API. This module factors out that shared plumbing. +// ============================================================================ + +/** + * Minimal config shape shared by session ({@link ResolveSessionConfigResult}) + * and root ({@link RootConfigState}) configuration. + */ +export interface IAgentHostConfigLike { + readonly schema: ConfigSchema; + readonly values: Record; +} + +/** + * Filter applied to schema properties to decide which ones surface in the + * editable document (and in the derived JSON schema). + * + * For session settings this filters to `sessionMutable && !readOnly`. For + * host settings all properties are editable, so the filter is a constant + * `true`. + */ +export type AgentHostConfigPropertyFilter = (key: string, schema: ConfigPropertySchema) => boolean; + +/** + * Localized strings used to decorate the serialized JSONC document. + */ +export interface IAgentHostSettingsLocale { + /** Header comment line describing the document. */ + readonly header: string; + /** Secondary hint comment describing save semantics. */ + readonly saveHint: string; + /** Error message thrown when the document fails to parse as JSONC. */ + readonly parseError: string; + /** Error message thrown when the parsed document is not a JSON object. */ + readonly notObject: string; +} + +/** + * Convert a config property schema (protocol shape) into an + * {@link IJSONSchema} suitable for registration with the JSON language + * service. + */ +export function convertPropertySchema(schema: ConfigPropertySchema): IJSONSchema { + const out: IJSONSchema = { + type: schema.type, + title: schema.title, + description: schema.description, + default: schema.default, + }; + if (schema.enum && schema.enum.length > 0) { + out.enum = [...schema.enum]; + if (schema.enumDescriptions && schema.enumDescriptions.length > 0) { + out.enumDescriptions = [...schema.enumDescriptions]; + } + } + if (schema.type === 'array' && schema.items) { + out.items = convertPropertySchema(schema.items); + } + if (schema.type === 'object' && schema.properties) { + const properties: Record = {}; + for (const [key, value] of Object.entries(schema.properties)) { + properties[key] = convertPropertySchema(value); + } + out.properties = properties; + if (schema.required && schema.required.length > 0) { + out.required = [...schema.required]; + } + } + return out; +} + +/** + * Build a JSON schema describing the filtered properties of an agent-host + * config. Properties that pass {@link filter} are included; others are + * dropped. `required` entries are carried through when the referenced + * property survives the filter. + */ +export function buildAgentHostConfigJsonSchema(config: IAgentHostConfigLike, filter: AgentHostConfigPropertyFilter): IJSONSchema { + const properties: Record = {}; + const required: string[] = []; + for (const [key, schema] of Object.entries(config.schema.properties)) { + if (!filter(key, schema)) { + continue; + } + properties[key] = convertPropertySchema(schema); + if (config.schema.required?.includes(key)) { + required.push(key); + } + } + const result: IJSONSchema = { + type: 'object', + properties, + additionalProperties: true, + }; + if (required.length > 0) { + result.required = required; + } + return result; +} + +function buildHeaderComment( + locale: IAgentHostSettingsLocale, + props: readonly (readonly [string, ConfigPropertySchema])[] | undefined, +): string { + const lines: string[] = []; + lines.push(`// ${locale.header}`); + lines.push(`// ${locale.saveHint}`); + if (props && props.length > 0) { + lines.push('//'); + for (const [key, schema] of props) { + const suffix = schema.enum && schema.enum.length > 0 ? ` (${schema.enum.join(' | ')})` : ''; + const title = schema.title || key; + lines.push(`// ${key}: ${title}${suffix}`); + if (schema.description) { + lines.push(`// ${schema.description}`); + } + } + } + lines.push(''); + return lines.join('\n'); +} + +/** + * Serialize the filtered config values into a commented, pretty-printed + * JSONC document. + */ +export function serializeAgentHostConfigDocument( + config: IAgentHostConfigLike | undefined, + filter: AgentHostConfigPropertyFilter, + locale: IAgentHostSettingsLocale, +): string { + if (!config) { + return `${buildHeaderComment(locale, undefined)}{}\n`; + } + + const editableProps = Object.entries(config.schema.properties).filter(([key, schema]) => filter(key, schema)); + const values: Record = {}; + for (const [key] of editableProps) { + if (config.values[key] !== undefined) { + values[key] = config.values[key]; + } + } + + return `${buildHeaderComment(locale, editableProps)}${JSON.stringify(values, null, 2)}\n`; +} + +// ============================================================================ +// AbstractAgentHostConfigFileSystemProvider +// ============================================================================ + +/** + * Base context shared by all settings filesystem providers. Subclasses + * extend with any additional state they need (e.g. a sessionId). + */ +export interface IAgentHostSettingsContext { + readonly providerId: string; +} + +/** + * Abstract filesystem provider backing the synthetic agent-host settings + * JSONC editors. Subclasses supply scheme-specific URI parsing, + * config-fetching, change-watching, and replace-dispatch hooks; the base + * handles the boilerplate (`stat`/`readFile`/`writeFile`/error shapes). + */ +export abstract class AbstractAgentHostConfigFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { + + readonly capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive; + + private readonly _onDidChangeCapabilities = this._register(new Emitter()); + readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event; + + protected readonly _onDidChangeFile = this._register(new Emitter()); + readonly onDidChangeFile = this._onDidChangeFile.event; + + constructor( + @ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService, + @ILogService protected readonly _logService: ILogService, + ) { + super(); + } + + // ---- Subclass hooks ----------------------------------------------------- + + /** URI scheme label used in error messages (e.g. `'agent-session-settings'`). */ + protected abstract readonly _schemeLabel: string; + + /** Log trace-tag (e.g. `'AgentSessionSettings'`). */ + protected abstract readonly _traceTag: string; + + /** Localized strings for the JSONC document and write-path errors. */ + protected abstract readonly _locale: IAgentHostSettingsLocale; + + /** Parse a URI of the subclass's scheme into a typed context. */ + protected abstract _parseUri(resource: URI): TContext | undefined; + + /** Render the current config for a context as a JSONC document. */ + protected abstract _serialize(provider: IAgentHostSessionsProvider, ctx: TContext): string; + + /** + * Subscribe for changes relevant to the given context. When a change is + * detected the subclass should invoke {@link fire}. + */ + protected abstract _watchChanges(provider: IAgentHostSessionsProvider, ctx: TContext, fire: () => void): IDisposable; + + /** Register / refresh the JSON schema for the given context. */ + protected abstract _ensureSchemaRegistered(provider: IAgentHostSessionsProvider, ctx: TContext): void; + + /** Whether the backing config is currently available. */ + protected abstract _hasConfig(provider: IAgentHostSessionsProvider, ctx: TContext): boolean; + + /** Dispatch a replace write of the parsed JSONC document. */ + protected abstract _replaceConfig(provider: IAgentHostSessionsProvider, ctx: TContext, values: Record): Promise; + + /** + * Build a short human-readable description of `ctx` for log messages + * when a write is ignored due to missing config (e.g. a session id). + */ + protected abstract _describeForTrace(ctx: TContext): string; + + // ---- IFileSystemProvider ------------------------------------------------ + + watch(resource: URI, _opts: IWatchOptions): IDisposable { + const parsed = this._parseUri(resource); + if (!parsed) { + return Disposable.None; + } + const provider = this._lookupProvider(parsed.providerId); + if (!provider) { + return Disposable.None; + } + return this._watchChanges(provider, parsed, () => { + this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); + }); + } + + async stat(resource: URI): Promise { + const { provider, ctx } = this._resolveOrThrow(resource); + const content = this._serialize(provider, ctx); + return { + type: FileType.File, + ctime: 0, + mtime: 0, + size: VSBuffer.fromString(content).byteLength, + permissions: 0 as FilePermission, + }; + } + + async readdir(): Promise<[string, FileType][]> { + throw createFileSystemProviderError('readdir not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async readFile(resource: URI): Promise { + const { provider, ctx } = this._resolveOrThrow(resource); + const content = this._serialize(provider, ctx); + + // Register the JSON schema on demand the first time a settings file + // is read. The subclass keeps it in sync from then on. + this._ensureSchemaRegistered(provider, ctx); + + return VSBuffer.fromString(content).buffer; + } + + async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise { + const { provider, ctx } = this._resolveOrThrow(resource); + + const text = VSBuffer.wrap(content).toString(); + const errors: ParseError[] = []; + const parsed_json = parse(text, errors); + if (errors.length > 0) { + throw createFileSystemProviderError(this._locale.parseError, FileSystemProviderErrorCode.Unavailable); + } + if (parsed_json === null || typeof parsed_json !== 'object' || Array.isArray(parsed_json)) { + throw createFileSystemProviderError(this._locale.notObject, FileSystemProviderErrorCode.Unavailable); + } + + if (!this._hasConfig(provider, ctx)) { + this._logService.trace(`[${this._traceTag}] No config state for ${this._describeForTrace(ctx)}; ignoring write.`); + this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); + return; + } + + await this._replaceConfig(provider, ctx, parsed_json as Record); + + this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); + } + + async mkdir(): Promise { + throw createFileSystemProviderError('mkdir not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { + throw createFileSystemProviderError('delete not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { + throw createFileSystemProviderError('rename not supported', FileSystemProviderErrorCode.NoPermissions); + } + + // ---- Helpers ------------------------------------------------------------ + + protected _lookupProvider(providerId: string): IAgentHostSessionsProvider | undefined { + const provider = this._sessionsProvidersService.getProvider(providerId); + if (!provider || !isAgentHostProvider(provider)) { + return undefined; + } + return provider; + } + + private _resolveOrThrow(resource: URI): { provider: IAgentHostSessionsProvider; ctx: TContext } { + const ctx = this._parseUri(resource); + if (!ctx) { + throw createFileSystemProviderError(`Invalid ${this._schemeLabel} URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); + } + const provider = this._lookupProvider(ctx.providerId); + if (!provider) { + throw createFileSystemProviderError(`Unknown agent host provider: ${ctx.providerId}`, FileSystemProviderErrorCode.FileNotFound); + } + return { provider, ctx }; + } +} + +// ============================================================================ +// AbstractAgentHostConfigSchemaRegistrar +// ============================================================================ + +/** + * Abstract base for the schema registrars that keep JSON schemas registered + * on the {@link IJSONContributionRegistry} for the synthetic settings + * editors. Subclasses plumb per-provider subscriptions and the target-type + * that identifies what a schema belongs to (an `ISession` for the session + * editor, an `IAgentHostSessionsProvider` for the host editor). + * + * Registration is lazy — {@link ensureRegistered} is called by the + * filesystem provider when a settings file is first read. Once registered, + * the schema is kept in sync via the subclass's change subscription until + * the provider is removed. + */ +export abstract class AbstractAgentHostConfigSchemaRegistrar extends Disposable { + + private readonly _schemaRegistry = Registry.as(JSONExtensions.JSONContribution); + + /** Per-provider subscriptions. */ + private readonly _providerSubscriptions = this._register(new DisposableMap()); + + /** Per-target registered-schema disposables, keyed by the settings URI string. */ + private readonly _targetSchemas = this._register(new DisposableMap()); + + /** + * Tracks the {@link ConfigSchema} identity last used to register a schema + * for a given settings URI so we can skip re-registration when only + * values have changed. + */ + private readonly _lastSchemaIdentity = new Map(); + + constructor( + @ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + for (const provider of this._sessionsProvidersService.getProviders()) { + this._onProviderAdded(provider); + } + this._register(this._sessionsProvidersService.onDidChangeProviders(e => { + for (const provider of e.added) { + this._onProviderAdded(provider); + } + for (const provider of e.removed) { + this._providerSubscriptions.deleteAndDispose(provider.id); + } + })); + } + + // ---- Subclass hooks ----------------------------------------------------- + + /** Stringified URI identifying the settings document for a target. */ + protected abstract _settingsUri(target: TTarget): string; + + /** `vscode://schemas/...` schema id used for JSON language service registration. */ + protected abstract _schemaId(target: TTarget): string; + + /** Fetch the backing config for a target. Returns `undefined` when none yet. */ + protected abstract _getConfig(provider: IAgentHostSessionsProvider, target: TTarget): IAgentHostConfigLike | undefined; + + /** Filter applied to schema properties when building the JSON schema. */ + protected abstract _propertyFilter(): AgentHostConfigPropertyFilter; + + /** Enumerate the targets currently tracked on a provider (used for cleanup). */ + protected abstract _targetsForProvider(provider: IAgentHostSessionsProvider): readonly TTarget[]; + + /** + * Subscribe to change signals from {@link provider}. The subclass should + * invoke {@link onChanged} when a tracked target's config changes and + * {@link onRemoved} when a tracked target disappears. + */ + protected abstract _observeProvider( + provider: IAgentHostSessionsProvider, + onChanged: (target: TTarget) => void, + onRemoved: (target: TTarget) => void, + ): IDisposable; + + // ---- Public API --------------------------------------------------------- + + /** + * Ensures a JSON schema is registered for the given target. Safe to + * call repeatedly; a no-op when the cached schema identity matches. + */ + ensureRegistered(provider: IAgentHostSessionsProvider, target: TTarget): void { + this._refreshSchema(provider, target); + } + + // ---- Internal ----------------------------------------------------------- + + private _onProviderAdded(provider: ISessionsProvider): void { + if (!isAgentHostProvider(provider)) { + return; + } + const store = new DisposableStore(); + + store.add(this._observeProvider( + provider, + target => { + // Only refresh if we already have a registration; otherwise the + // next `readFile` will pick up the latest schema on demand. + if (!this._lastSchemaIdentity.has(this._settingsUri(target))) { + return; + } + this._refreshSchema(provider, target); + }, + target => this._disposeSchemaForTarget(target), + )); + + // On provider disposal, drop all schemas registered for this provider. + store.add(toDisposable(() => { + for (const target of this._targetsForProvider(provider)) { + this._disposeSchemaForTarget(target); + } + })); + + this._providerSubscriptions.set(provider.id, store); + } + + private _refreshSchema(provider: IAgentHostSessionsProvider, target: TTarget): void { + const config = this._getConfig(provider, target); + if (!config) { + return; + } + const settingsUri = this._settingsUri(target); + const identity = config.schema; + if (this._lastSchemaIdentity.get(settingsUri) === identity) { + return; + } + + const schema = buildAgentHostConfigJsonSchema(config, this._propertyFilter()); + const schemaId = this._schemaId(target); + + // Dispose any prior registration first, otherwise the old cleanup + // disposable would delete the freshly registered schema. + this._targetSchemas.deleteAndDispose(settingsUri); + + const store = new DisposableStore(); + this._schemaRegistry.registerSchema(schemaId, schema, store); + store.add(this._schemaRegistry.registerSchemaAssociation(schemaId, settingsUri)); + store.add(toDisposable(() => this._lastSchemaIdentity.delete(settingsUri))); + + this._targetSchemas.set(settingsUri, store); + this._lastSchemaIdentity.set(settingsUri, identity); + } + + private _disposeSchemaForTarget(target: TTarget): void { + this._targetSchemas.deleteAndDispose(this._settingsUri(target)); + } +} diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts index cd0caf3c3ace8..19c56865fbb4f 100644 --- a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts @@ -3,37 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { parse, ParseError } from '../../../../base/common/json.js'; import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { - createFileSystemProviderError, - FileChangeType, - FilePermission, - FileSystemProviderCapabilities, - FileSystemProviderErrorCode, - FileType, - IFileChange, - IFileDeleteOptions, - IFileOverwriteOptions, - IFileSystemProviderWithFileReadWriteCapability, - IFileWriteOptions, - IStat, - IWatchOptions, -} from '../../../../platform/files/common/files.js'; -import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { SessionConfigPropertySchema, SessionConfigSchema } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ISession, toSessionId } from '../../../services/sessions/common/session.js'; +import { SessionConfigPropertySchema } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; -import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; +import { ISession, toSessionId } from '../../../services/sessions/common/session.js'; +import { + AbstractAgentHostConfigFileSystemProvider, + AbstractAgentHostConfigSchemaRegistrar, + AgentHostConfigPropertyFilter, + buildAgentHostConfigJsonSchema, + IAgentHostConfigLike, + IAgentHostSettingsContext, + IAgentHostSettingsLocale, + serializeAgentHostConfigDocument, +} from './agentHostSettingsShared.js'; /** Scheme for the synthetic agent-host session settings files. */ export const AGENT_SESSION_SETTINGS_SCHEME = 'agent-session-settings'; @@ -44,8 +33,8 @@ export const AGENT_SESSION_SETTINGS_SCHEME = 'agent-session-settings'; * URI shape: `agent-session-settings://{providerId}/{resourceScheme}{resourcePath}.jsonc` * * - `authority` = {@link ISession.providerId} (e.g. `local-agent-host`, `agenthost-`) - * - path encodes the session's resource scheme and path so {@link parseSettingsUri} - * can reconstruct the full {@link ISession.sessionId} via {@link toSessionId} + * - path encodes the session's resource scheme and path so the URI can be + * parsed back into an {@link ISession.sessionId} via {@link toSessionId} * without having to look the session up on the provider. */ export function agentSessionSettingsUri(session: ISession): URI { @@ -57,13 +46,12 @@ export function agentSessionSettingsUri(session: ISession): URI { }); } -interface IParsedSettingsUri { - readonly providerId: string; +interface ISessionSettingsContext extends IAgentHostSettingsContext { /** Reconstructed {@link ISession.sessionId}. */ readonly sessionId: string; } -function parseSettingsUri(uri: URI): IParsedSettingsUri | undefined { +function parseSessionSettingsUri(uri: URI): ISessionSettingsContext | undefined { if (uri.scheme !== AGENT_SESSION_SETTINGS_SCHEME) { return undefined; } @@ -90,434 +78,148 @@ function parseSettingsUri(uri: URI): IParsedSettingsUri | undefined { return { providerId, sessionId: toSessionId(providerId, resource) }; } +/** + * Property filter: only session-mutable, non-read-only properties are + * editable. Read-only / non-mutable properties (e.g. `isolation`, `branch`) + * are preserved in the underlying config and round-tripped on write — they + * just aren't surfaced for editing. + */ +const sessionSettingsPropertyFilter: AgentHostConfigPropertyFilter = (_key, schema) => { + const s = schema as SessionConfigPropertySchema; + return s.sessionMutable === true && s.readOnly !== true; +}; + +const sessionSettingsLocale: IAgentHostSettingsLocale = { + get header() { return localize('agentSessionSettings.header', "Session settings for this agent host session."); }, + get saveHint() { return localize('agentSessionSettings.saveHint', "Edit values below and save to apply. Unknown or non-mutable properties are ignored."); }, + get parseError() { return localize('agentSessionSettings.parseError', "Failed to parse agent session settings as JSON."); }, + get notObject() { return localize('agentSessionSettings.notObject', "Agent session settings must be a JSON object."); }, +}; + /** * Serialize the session-mutable config values for a session into a * commented, pretty-printed JSON document. */ export function serializeSessionSettings(provider: IAgentHostSessionsProvider, sessionId: string): string { - const config = provider.getSessionConfig(sessionId); - if (!config) { - return `${headerComment(undefined)}{}\n`; - } - - // Only include session-mutable, non-readOnly properties in the editable - // document. Read-only / non-mutable properties (e.g. `isolation`, `branch`) - // are preserved in the underlying config and round-tripped on write — - // they just aren't surfaced for editing. - const mutableProps = Object.entries(config.schema.properties).filter(([, schema]) => schema.sessionMutable && !schema.readOnly); - const values: Record = {}; - for (const [key] of mutableProps) { - if (config.values[key] !== undefined) { - values[key] = config.values[key]; - } - } - - return `${headerComment(mutableProps)}${JSON.stringify(values, null, 2)}\n`; + return serializeAgentHostConfigDocument(provider.getSessionConfig(sessionId), sessionSettingsPropertyFilter, sessionSettingsLocale); } -function headerComment(props: readonly (readonly [string, { readonly title: string; readonly description?: string; readonly enum?: readonly string[] }])[] | undefined): string { - const lines: string[] = []; - lines.push(`// ${localize('agentSessionSettings.header', "Session settings for this agent host session.")}`); - lines.push(`// ${localize('agentSessionSettings.saveHint', "Edit values below and save to apply. Unknown or non-mutable properties are ignored.")}`); - if (props && props.length > 0) { - lines.push('//'); - for (const [key, schema] of props) { - const suffix = schema.enum && schema.enum.length > 0 ? ` (${schema.enum.join(' | ')})` : ''; - const title = schema.title || key; - lines.push(`// ${key}: ${title}${suffix}`); - if (schema.description) { - lines.push(`// ${schema.description}`); - } - } - } - lines.push(''); - return lines.join('\n'); +/** + * Build a JSON schema describing the editable session-mutable, non-readOnly + * properties of an agent-host session config. The filter mirrors the one + * used by {@link serializeSessionSettings} so validation matches the file + * contents produced by this provider. + */ +export function buildSessionSettingsJsonSchema(config: ResolveSessionConfigResult): IJSONSchema { + return buildAgentHostConfigJsonSchema(config, sessionSettingsPropertyFilter); } /** * Filesystem provider serving synthetic JSONC documents that represent the * session-mutable config values of agent-host sessions. - * - * Reads render `IAgentHostSessionsProvider.getSessionConfig()` as pretty - * JSONC. Writes parse the document with the JSONC parser and push the user's - * full editable view to `replaceSessionConfig`, which atomically replaces - * user-editable values while preserving non-mutable / readOnly properties - * (e.g. `isolation`, `branch`) server-side. */ -export class AgentSessionSettingsFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { - - readonly capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive; - - private readonly _onDidChangeCapabilities = this._register(new Emitter()); - readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event; +export class AgentSessionSettingsFileSystemProvider extends AbstractAgentHostConfigFileSystemProvider { - private readonly _onDidChangeFile = this._register(new Emitter()); - readonly onDidChangeFile = this._onDidChangeFile.event; + protected readonly _schemeLabel = AGENT_SESSION_SETTINGS_SCHEME; + protected readonly _traceTag = 'AgentSessionSettings'; + protected readonly _locale = sessionSettingsLocale; constructor( private readonly _schemaRegistrar: AgentSessionSettingsSchemaRegistrar, - @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, - @ILogService private readonly _logService: ILogService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ILogService logService: ILogService, ) { - super(); + super(sessionsProvidersService, logService); } - watch(resource: URI, _opts: IWatchOptions): IDisposable { - // The underlying provider fires `onDidChangeSessionConfig` with a sessionId; - // forward those into `onDidChangeFile` for the watched resource. - const parsed = parseSettingsUri(resource); - if (!parsed) { - return Disposable.None; - } - const provider = this._lookupProvider(parsed.providerId); - if (!provider) { - return Disposable.None; - } - return provider.onDidChangeSessionConfig(changedSessionId => { - if (changedSessionId === parsed.sessionId) { - this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); - } - }); + protected _parseUri(resource: URI): ISessionSettingsContext | undefined { + return parseSessionSettingsUri(resource); } - async stat(resource: URI): Promise { - const parsed = parseSettingsUri(resource); - if (!parsed) { - throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); - } - const { provider, sessionId } = this._resolve(parsed); - const content = serializeSessionSettings(provider, sessionId); - return { - type: FileType.File, - ctime: 0, - mtime: 0, - size: VSBuffer.fromString(content).byteLength, - permissions: 0 as FilePermission, - }; + protected _serialize(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): string { + return serializeSessionSettings(provider, ctx.sessionId); } - async readdir(): Promise<[string, FileType][]> { - throw createFileSystemProviderError('readdir not supported', FileSystemProviderErrorCode.NoPermissions); + protected _watchChanges(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext, fire: () => void): IDisposable { + return provider.onDidChangeSessionConfig(changedSessionId => { + if (changedSessionId === ctx.sessionId) { + fire(); + } + }); } - async readFile(resource: URI): Promise { - const parsed = parseSettingsUri(resource); - if (!parsed) { - throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); - } - const { provider, sessionId } = this._resolve(parsed); - const content = serializeSessionSettings(provider, sessionId); - - // Register the JSON schema on demand the first time a settings file - // is read. The registrar keeps it in sync from then on. - const session = provider.getSessions().find(s => s.sessionId === sessionId); + protected _ensureSchemaRegistered(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): void { + const session = provider.getSessions().find(s => s.sessionId === ctx.sessionId); if (session) { - this._schemaRegistrar.ensureRegistered(session); - } - - return VSBuffer.fromString(content).buffer; - } - - async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise { - const parsed = parseSettingsUri(resource); - if (!parsed) { - throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); - } - const { provider, sessionId } = this._resolve(parsed); - - const text = VSBuffer.wrap(content).toString(); - const errors: ParseError[] = []; - const parsed_json = parse(text, errors); - if (errors.length > 0) { - throw createFileSystemProviderError( - localize('agentSessionSettings.parseError', "Failed to parse agent session settings as JSON."), - FileSystemProviderErrorCode.Unavailable, - ); - } - if (parsed_json === null || typeof parsed_json !== 'object' || Array.isArray(parsed_json)) { - throw createFileSystemProviderError( - localize('agentSessionSettings.notObject', "Agent session settings must be a JSON object."), - FileSystemProviderErrorCode.Unavailable, - ); + this._schemaRegistrar.ensureRegistered(provider, session); } - - const currentConfig = provider.getSessionConfig(sessionId); - if (!currentConfig) { - this._logService.trace(`[AgentSessionSettings] No config state for session ${sessionId}; ignoring write.`); - this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); - return; - } - - // The input is the user's full view of editable values. Dispatch as a - // replace — `replaceSessionConfig` guarantees non-editable properties - // (non-mutable or readOnly) are preserved regardless of what we send, - // and unknown keys are ignored. This means: - // - Re-asserted editable keys overwrite the current value. - // - Omitted editable keys are unset (supports clearing via deletion). - // - Non-editable keys (e.g. `isolation`, `branch`) are round-tripped - // server-side even though we never read or write them here. - await provider.replaceSessionConfig(sessionId, parsed_json as Record); - - this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); - } - - async mkdir(): Promise { - throw createFileSystemProviderError('mkdir not supported', FileSystemProviderErrorCode.NoPermissions); } - async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { - throw createFileSystemProviderError('delete not supported', FileSystemProviderErrorCode.NoPermissions); + protected _hasConfig(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): boolean { + return provider.getSessionConfig(ctx.sessionId) !== undefined; } - async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { - throw createFileSystemProviderError('rename not supported', FileSystemProviderErrorCode.NoPermissions); - } - - // ---- Helpers ------------------------------------------------------------ - - private _lookupProvider(providerId: string): IAgentHostSessionsProvider | undefined { - const provider = this._sessionsProvidersService.getProvider(providerId); - if (!provider || !isAgentHostProvider(provider)) { - return undefined; - } - return provider; + // The input is the user's full view of editable values. Dispatch as a + // replace — `replaceSessionConfig` guarantees non-editable properties + // (non-mutable or readOnly) are preserved regardless of what we send, + // and unknown keys are ignored. + protected _replaceConfig(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext, values: Record): Promise { + return provider.replaceSessionConfig(ctx.sessionId, values); } - private _resolve(parsed: IParsedSettingsUri): { provider: IAgentHostSessionsProvider; sessionId: string } { - const provider = this._lookupProvider(parsed.providerId); - if (!provider) { - throw createFileSystemProviderError( - `Unknown agent host provider: ${parsed.providerId}`, - FileSystemProviderErrorCode.FileNotFound, - ); - } - return { provider, sessionId: parsed.sessionId }; + protected _describeForTrace(ctx: ISessionSettingsContext): string { + return `session ${ctx.sessionId}`; } } /** - * Convert a session config property schema (protocol shape) into an - * {@link IJSONSchema} suitable for registration with the JSON language - * service. - */ -function convertPropertySchema(schema: SessionConfigPropertySchema): IJSONSchema { - const out: IJSONSchema = { - type: schema.type, - title: schema.title, - description: schema.description, - default: schema.default, - }; - if (schema.enum && schema.enum.length > 0) { - out.enum = [...schema.enum]; - if (schema.enumDescriptions && schema.enumDescriptions.length > 0) { - out.enumDescriptions = [...schema.enumDescriptions]; - } - } - if (schema.type === 'array' && schema.items) { - out.items = convertPropertySchema(schema.items); - } - if (schema.type === 'object' && schema.properties) { - const properties: Record = {}; - for (const [key, value] of Object.entries(schema.properties)) { - properties[key] = convertPropertySchema(value); - } - out.properties = properties; - if (schema.required && schema.required.length > 0) { - out.required = [...schema.required]; - } - } - return out; -} - -/** - * Build a JSON schema describing the editable session-mutable, non-readOnly - * properties of an agent-host session config. The filter mirrors the one in - * {@link serializeSessionSettings} so validation matches the file contents - * produced by this provider. - */ -export function buildSessionSettingsJsonSchema(config: ResolveSessionConfigResult): IJSONSchema { - const properties: Record = {}; - const required: string[] = []; - for (const [key, schema] of Object.entries(config.schema.properties)) { - if (!schema.sessionMutable || schema.readOnly) { - continue; - } - properties[key] = convertPropertySchema(schema); - if (config.schema.required?.includes(key)) { - required.push(key); - } - } - const result: IJSONSchema = { - type: 'object', - properties, - additionalProperties: true, - }; - if (required.length > 0) { - result.required = required; - } - return result; -} - -/** - * Keeps per-session JSON schemas registered on the - * {@link IJSONContributionRegistry} so editors of the synthetic + * Keeps per-session JSON schemas registered so editors of the synthetic * `agent-session-settings://…` files get completions, hover, and validation. - * - * Registration is lazy — {@link ensureRegistered} is called by - * {@link AgentSessionSettingsFileSystemProvider.readFile} the first time a - * session's settings document is read, so we avoid the JSON language - * service overhead for sessions that are never opened. Once registered, the - * schema is kept in sync via `onDidChangeSessionConfig` until the session - * or its provider is removed. - * - * A schema is rebuilt only when the session's underlying - * {@link SessionConfigSchema} changes by identity (protocol config schemas - * are treated as immutable snapshots); value-only changes are ignored to - * avoid churning the JSON language service. */ -export class AgentSessionSettingsSchemaRegistrar extends Disposable { +export class AgentSessionSettingsSchemaRegistrar extends AbstractAgentHostConfigSchemaRegistrar { - private readonly _schemaRegistry = Registry.as(JSONExtensions.JSONContribution); - - /** Per-provider subscriptions (session listeners, config listeners). */ - private readonly _providerSubscriptions = this._register(new DisposableMap()); + protected _propertyFilter(): AgentHostConfigPropertyFilter { + return sessionSettingsPropertyFilter; + } - /** Per-session registered-schema disposables, keyed by the settings URI string. */ - private readonly _sessionSchemas = this._register(new DisposableMap()); + protected _settingsUri(session: ISession): string { + return agentSessionSettingsUri(session).toString(); + } - /** - * Tracks the {@link SessionConfigSchema} identity last used to register - * a schema for a given settings URI, so we can skip re-registration when - * only values have changed. - */ - private readonly _lastSchemaIdentity = new Map(); + // Schema content is served via the `vscode://schemas/...` filesystem + // provider (see `SettingsFileSystemProvider`); the JSON language client + // only knows how to fetch schema content for that scheme. The + // settings-file URI is used as the fileMatch glob so the schema is + // applied to the actual editor document. + protected _schemaId(session: ISession): string { + return `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`; + } - constructor( - @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, - ) { - super(); + protected _getConfig(provider: IAgentHostSessionsProvider, session: ISession): IAgentHostConfigLike | undefined { + return provider.getSessionConfig(session.sessionId); + } - for (const provider of this._sessionsProvidersService.getProviders()) { - this._onProviderAdded(provider); - } - this._register(this._sessionsProvidersService.onDidChangeProviders(e => { - for (const provider of e.added) { - this._onProviderAdded(provider); - } - for (const provider of e.removed) { - this._onProviderRemoved(provider); - } - })); + protected _targetsForProvider(provider: IAgentHostSessionsProvider): readonly ISession[] { + return provider.getSessions(); } - private _onProviderAdded(provider: ISessionsProvider): void { - if (!isAgentHostProvider(provider)) { - return; - } + protected _observeProvider( + provider: IAgentHostSessionsProvider, + onChanged: (session: ISession) => void, + onRemoved: (session: ISession) => void, + ): IDisposable { const store = new DisposableStore(); - - // Note: we do NOT seed schemas eagerly here — registration is lazy and - // only happens on the first `readFile` for a given session via - // {@link ensureRegistered}. Registering schemas is relatively expensive - // for the JSON language service, so we avoid paying that cost for - // sessions whose settings files are never opened. - store.add(provider.onDidChangeSessionConfig(sessionId => { - const schemaUri = this._schemaUriForSession(provider.id, sessionId); - // Only refresh if we already have a registration; otherwise the - // next `readFile` will pick up the latest schema on demand. - if (!schemaUri || !this._lastSchemaIdentity.has(schemaUri)) { - return; - } const session = provider.getSessions().find(s => s.sessionId === sessionId); if (session) { - this._refreshSchema(provider, session); + onChanged(session); } })); - store.add(provider.onDidChangeSessions(e => { for (const removed of e.removed) { - this._disposeSchema(removed); - } - })); - - // On provider disposal, drop all session schemas for this provider. - store.add(toDisposable(() => { - for (const session of provider.getSessions()) { - this._disposeSchema(session); + onRemoved(removed); } })); - - this._providerSubscriptions.set(provider.id, store); - } - - private _onProviderRemoved(provider: ISessionsProvider): void { - this._providerSubscriptions.deleteAndDispose(provider.id); - } - - /** - * Ensures a JSON schema is registered for the given session. Called - * lazily by the filesystem provider when a settings file is first read - * so we avoid the cost of registering schemas for sessions that are - * never opened. - * - * Once registered, the schema is kept in sync via - * `onDidChangeSessionConfig` until the session or its provider is - * removed. - */ - ensureRegistered(session: ISession): void { - const provider = this._sessionsProvidersService.getProvider(session.providerId); - if (!provider || !isAgentHostProvider(provider)) { - return; - } - this._refreshSchema(provider, session); - } - - private _schemaUriForSession(providerId: string, sessionId: string): string | undefined { - const provider = this._sessionsProvidersService.getProvider(providerId); - if (!provider || !isAgentHostProvider(provider)) { - return undefined; - } - const session = provider.getSessions().find(s => s.sessionId === sessionId); - return session ? agentSessionSettingsUri(session).toString() : undefined; - } - - private _refreshSchema(provider: IAgentHostSessionsProvider, session: ISession): void { - const config = provider.getSessionConfig(session.sessionId); - if (!config) { - return; - } - const settingsUri = agentSessionSettingsUri(session).toString(); - // Schema content is served via the `vscode://schemas/...` filesystem - // provider (see `SettingsFileSystemProvider`); the JSON language - // client only knows how to fetch schema content for that scheme. - // The settings-file URI is used as the fileMatch glob so the schema - // is applied to the actual editor document. - const schemaId = `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`; - const identity = config.schema; - if (this._lastSchemaIdentity.get(settingsUri) === identity) { - return; - } - - const schema = buildSessionSettingsJsonSchema(config); - - // Dispose any prior registration first, otherwise the old cleanup - // disposable would delete the freshly registered schema. Clear the - // identity cache as a side effect so we always proceed to register. - this._sessionSchemas.deleteAndDispose(settingsUri); - - const store = new DisposableStore(); - this._schemaRegistry.registerSchema(schemaId, schema, store); - store.add(this._schemaRegistry.registerSchemaAssociation(schemaId, settingsUri)); - store.add(toDisposable(() => this._lastSchemaIdentity.delete(settingsUri))); - - this._sessionSchemas.set(settingsUri, store); - this._lastSchemaIdentity.set(settingsUri, identity); - } - - private _disposeSchema(session: ISession): void { - const schemaUri = agentSessionSettingsUri(session).toString(); - this._sessionSchemas.deleteAndDispose(schemaUri); + return store; } } - diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index 2e6e07c72cdd1..bb086202a32a8 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -17,7 +17,7 @@ import { localize } from '../../../../nls.js'; import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; -import { FileEdit, ModelSelection, RootState, SessionState, SessionSummary, SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { FileEdit, ModelSelection, RootConfigState, RootState, SessionState, SessionSummary, SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; @@ -249,6 +249,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement protected readonly _onDidChangeSessionConfig = this._register(new Emitter()); readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event; + protected readonly _onDidChangeRootConfig = this._register(new Emitter()); + readonly onDidChangeRootConfig = this._onDidChangeRootConfig.event; + + /** Last-known root config state (schema + values), seeded from `RootState.config`. */ + protected _rootConfig: RootConfigState | undefined; + /** Cache of adapted sessions, keyed by raw session ID. */ protected readonly _sessionCache = new Map(); @@ -359,6 +365,28 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessionTypes.fire(); } + /** + * Reconcile {@link _rootConfig} against {@link RootState.config}, firing + * {@link onDidChangeRootConfig} only when schema or values actually change. + */ + protected _syncRootConfigFromRootState(rootState: RootState): void { + const next = rootState.config; + const prev = this._rootConfig; + if (prev === next) { + return; + } + if (!next) { + this._rootConfig = undefined; + this._onDidChangeRootConfig.fire(); + return; + } + if (prev && prev.schema === next.schema && equals(prev.values, next.values)) { + return; + } + this._rootConfig = next; + this._onDidChangeRootConfig.fire(); + } + abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined; /** Optional event fired when the underlying connection is lost; used to short-circuit `_waitForNewSession`. */ @@ -575,7 +603,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const nextValues: Record = {}; for (const [key, schema] of Object.entries(runningConfig.schema.properties)) { const editable = schema.sessionMutable === true && schema.readOnly !== true; - if (editable && Object.hasOwn(values, key)) { + if (editable) { nextValues[key] = values[key]; } else if (Object.hasOwn(runningConfig.values, key)) { nextValues[key] = runningConfig.values[key]; @@ -633,6 +661,67 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._clearNewSessionConfig(sessionId); } + // -- Root (agent host) Config -------------------------------------------- + + getRootConfig(): RootConfigState | undefined { + return this._rootConfig; + } + + async setRootConfigValue(property: string, value: unknown): Promise { + const current = this._rootConfig; + const connection = this.connection; + if (!current || !connection) { + return; + } + if (!current.schema.properties[property]) { + return; + } + + // Optimistically update local cache. + this._rootConfig = { + ...current, + values: { ...current.values, [property]: value }, + }; + this._onDidChangeRootConfig.fire(); + + const action = { + type: ActionType.RootConfigChanged as const, + config: { [property]: value }, + }; + connection.dispatch(action); + } + + async replaceRootConfig(values: Record): Promise { + const current = this._rootConfig; + const connection = this.connection; + if (!current || !connection) { + return; + } + + // Filter to known properties so we don't dispatch values for keys the + // host didn't publish a schema for. + const nextValues: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (current.schema.properties[key]) { + nextValues[key] = value; + } + } + + if (equals(nextValues, current.values)) { + return; + } + + this._rootConfig = { ...current, values: nextValues }; + this._onDidChangeRootConfig.fire(); + + const action = { + type: ActionType.RootConfigChanged as const, + config: nextValues, + replace: true, + }; + connection.dispatch(action); + } + // -- Model selection ------------------------------------------------------ setModel(sessionId: string, modelId: string): void { diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts index f492f082af181..8e7d9b521e4e7 100644 --- a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts @@ -69,9 +69,11 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide const rootStateValue = this._agentHostService.rootState.value; if (rootStateValue && !(rootStateValue instanceof Error)) { this._syncSessionTypesFromRootState(rootStateValue); + this._syncRootConfigFromRootState(rootStateValue); } this._register(this._agentHostService.rootState.onDidChange(rootState => { this._syncSessionTypesFromRootState(rootState); + this._syncRootConfigFromRootState(rootState); })); // Eagerly populate the session cache once authentication has settled. diff --git a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 279685b9008d0..2aadbca6d3cae 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -14,7 +14,7 @@ import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelSc import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { AgentSession, IAgentHostService, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; -import type { SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; +import type { RootAction, SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js'; import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -47,7 +47,7 @@ class MockAgentHostService extends mock() { override readonly clientId = 'test-local-client'; private readonly _sessions = new Map(); public disposedSessions: URI[] = []; - public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: RootAction | SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; @@ -93,11 +93,11 @@ class MockAgentHostService extends mock() { return this.resolveSessionConfigResult; } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 052afa16a8373..21507e78d283b 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -77,7 +77,11 @@ function createMockProvider(id: string, opts?: { getSessionConfigCompletions: async () => [], getCreateSessionConfig: () => undefined, clearSessionConfig: () => { }, - } as IAgentHostSessionsProvider; + onDidChangeRootConfig: Event.None, + getRootConfig: () => undefined, + setRootConfigValue: async () => { }, + replaceRootConfig: async () => { }, + } as unknown as IAgentHostSessionsProvider; } return base; } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index b26e06f4f6c51..aa9340eb9c6fd 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -359,9 +359,11 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid const rootStateValue = connection.rootState.value; if (rootStateValue && !(rootStateValue instanceof Error)) { this._syncSessionTypesFromRootState(rootStateValue); + this._syncRootConfigFromRootState(rootStateValue); } this._connectionListeners.add(connection.rootState.onDidChange(rootState => { this._syncSessionTypesFromRootState(rootState); + this._syncRootConfigFromRootState(rootState); })); this._attachConnectionListeners(connection, this._connectionListeners); diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 7799f9eabbe48..681bfb6c91f86 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -12,7 +12,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; -import type { SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; +import type { RootAction, SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js'; import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -49,7 +49,7 @@ class MockAgentConnection extends mock() { override readonly clientId = 'test-client-1'; private readonly _sessions = new Map(); public disposedSessions: URI[] = []; - public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: RootAction | SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; @@ -89,11 +89,11 @@ class MockAgentConnection extends mock() { return this.resolveSessionConfigResult; } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); } diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 0411c189ded57..98a2c4ec1d130 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -206,6 +206,7 @@ import './contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.js'; // Local Agent Host import './contrib/agentHost/browser/localAgentHost.contribution.js'; import './contrib/agentHost/browser/agentSessionSettings.contribution.js'; +import './contrib/agentHost/browser/agentHostSettings.contribution.js'; // Tunnel Host (allow remote connections to local agent host) import './contrib/tunnelHost/electron-browser/tunnelHost.contribution.js'; diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 6abba66db2c46..9fac1f377960c 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -156,6 +156,7 @@ import './contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.j import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js'; import './contrib/agentHost/browser/agentSessionSettings.contribution.js'; +import './contrib/agentHost/browser/agentHostSettings.contribution.js'; // Host filter dropdown in the titlebar (scopes the sessions list to a host) import './contrib/remoteAgentHost/browser/hostFilter.contribution.js'; 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 93a5537200ac9..265496d8c1a66 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -10,7 +10,7 @@ import { Registry } from '../../../../../../platform/registry/common/platform.js import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { StateComponents, type ComponentToState, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; @@ -216,7 +216,7 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._inner.getSubscriptionUnmanaged(kind, resource); } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { this._log('>>', 'dispatch', action); this._inner.dispatch(action); } 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 e13bdbb7cb532..02e99b4979a9f 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 @@ -16,7 +16,7 @@ import { timeout } from '../../../../../../base/common/async.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; -import { isSessionAction, type ActionEnvelope, type INotification, type SessionAction, type TerminalAction, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { isSessionAction, type ActionEnvelope, type INotification, type RootAction, type SessionAction, type TerminalAction, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { CustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, ROOT_STATE_URI, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -117,7 +117,7 @@ class MockAgentHostService extends mock() { // Protocol methods public override readonly clientId = 'test-window-1'; - public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: RootAction | SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; /** Returns dispatched actions filtered to turn-related types only * (excludes lifecycle actions like activeClientChanged). */ @@ -157,7 +157,7 @@ class MockAgentHostService extends mock() { }; } unsubscribe(_resource: URI): void { } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } private _nextSeq = 1; @@ -250,7 +250,7 @@ class MockAgentHostService extends mock() { onDidApplyAction: Event.None, } satisfies IAgentSubscription; } - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); // Apply state-management actions optimistically so state-dependent // logic (e.g. customization re-dispatch) sees the correct activeClient. diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 58add97bfc0d4..4bedaad86a720 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -14,7 +14,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { isSessionAction, type ActionEnvelope, type INotification, type SessionAction, type TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { isSessionAction, type ActionEnvelope, type INotification, type RootAction, type SessionAction, type TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionLifecycle, SessionStatus, createSessionState, StateComponents, type SessionState, type SessionSummary, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -283,9 +283,9 @@ suite('AgentHostClientTools', () => { override readonly onAgentHostStart = Event.None; private readonly _liveSubscriptions = new Map }>(); - public dispatchedActions: (SessionAction | TerminalAction)[] = []; + public dispatchedActions: (RootAction | SessionAction | TerminalAction)[] = []; - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push(action); if (isSessionAction(action) && action.type === 'session/activeClientChanged') { const entry = this._liveSubscriptions.get(action.session); diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index 18370f0de2fe7..134d47026e2b1 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -12,7 +12,7 @@ import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfig import { ActionType, StateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; import { RootState, TerminalClaimKind, type TerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import type { ActionEnvelope, SessionAction, TerminalAction, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import type { ActionEnvelope, RootAction, SessionAction, TerminalAction, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { AgentHostPty } from '../../browser/agentHostPty.js'; @@ -32,7 +32,7 @@ class MockAgentConnection implements IAgentConnection { private readonly _onDidNotification = new Emitter(); readonly onDidNotification: Event = this._onDidNotification.event; - readonly dispatchedActions: (SessionAction | TerminalAction)[] = []; + readonly dispatchedActions: (RootAction | SessionAction | TerminalAction)[] = []; readonly createdTerminals: CreateTerminalParams[] = []; readonly disposedTerminals: URI[] = []; readonly subscribedResources: URI[] = []; @@ -115,7 +115,7 @@ class MockAgentConnection implements IAgentConnection { getSubscriptionUnmanaged(_kind: StateComponents, _resource: URI): IAgentSubscription | undefined { return undefined; } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push(action); } From 0748f7f4d114cb844dd0626e8cdcc61014b4f687 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 24 Apr 2026 08:34:15 -0700 Subject: [PATCH 2/2] pr comments --- .../agentHost/common/agentHostSchema.ts | 8 ++++---- .../agentHost/node/sessionPermissions.ts | 3 ++- .../test/common/agentHostSchema.test.ts | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 88da94471d6be..33ebc3dacf585 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -117,7 +117,7 @@ export interface ISchema { * {@link values} or {@link assertValid} when you want a descriptive * {@link ProtocolError} instead. */ - validateOrDefault }>>(values: Record | undefined, defaults: T): Record; + validateOrDefault }>>(values: { [K in keyof T]?: unknown } | undefined, defaults: T): T; } export function createSchema(definition: D): ISchema { @@ -158,9 +158,9 @@ export function createSchema(definition: D): ISchema const narrowed: ISchemaProperty = prop; narrowed.assertValid(value, key); }, - validateOrDefault }>>(values: Record | undefined, defaults: T): Record { + validateOrDefault }>>(values: { [K in keyof T]?: unknown } | undefined, defaults: T): T { const result: Record = {}; - const raw = values ?? {}; + const raw: { [K in keyof T]?: unknown } = values ?? {}; for (const key of Object.keys(definition)) { const prop = definition[key]; const candidate = raw[key]; @@ -172,7 +172,7 @@ export function createSchema(definition: D): ISchema // else: key not in defaults and incoming value missing/invalid // → leave unset so higher-scope defaults can fill in. } - return result; + return result as T; }, }; } diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index a1955bf63d49d..304fc5fa8b430 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -11,7 +11,8 @@ import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import type { IAgentToolReadyEvent } from '../common/agentService.js'; import { platformSessionSchema } from '../common/agentHostSchema.js'; -import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js'; import { ResponsePartKind, diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index 8a3f806f1536e..eaee2c92cc130 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -254,9 +254,29 @@ suite('agentHostSchema', () => { test('ignores keys not in defaults', () => { const schema = fixture(); + // @ts-expect-error: test that extra keys not in the defaults are ignored, even if they pass validation. const result = schema.validateOrDefault({ name: 'a', count: 1, ignored: true }, { name: 'd', count: 0 }); assert.deepStrictEqual(result, { name: 'a', count: 1 }); }); + + test('omits schema keys that are missing from both values and defaults', () => { + // Regression coverage for the partial-defaults contract that + // underpins host-level inheritance: if the caller doesn't supply + // a default and no incoming value is valid, the key is left out + // entirely so higher-scope defaults can fill in. + const schema = fixture(); + const result = schema.validateOrDefault({ count: 9 }, { count: 0 }); + assert.deepStrictEqual(result, { count: 9 }); + assert.ok(!result.hasOwnProperty('name'), '`name` should be absent when neither values nor defaults supply it'); + }); + + test('omits schema keys when value is invalid and no default is supplied', () => { + const schema = fixture(); + // @ts-expect-error: test that invalid values are dropped even when the caller doesn't provide a default. + const result = schema.validateOrDefault({ name: 42, count: 3 }, { count: 0 }); + // `name` has no default and the incoming value is invalid → dropped. + assert.deepStrictEqual(result, { count: 3 }); + }); }); // ---- platformSessionSchema sanity --------------------------------------