Path: blob/main/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { VSBuffer } from '../../../../../base/common/buffer.js';7import { Emitter } from '../../../../../base/common/event.js';8import { DisposableStore } from '../../../../../base/common/lifecycle.js';9import { URI } from '../../../../../base/common/uri.js';10import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';11import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js';12import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';13import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';14import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js';15import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../../platform/jsonschemas/common/jsonContributionRegistry.js';16import { Registry } from '../../../../../platform/registry/common/platform.js';17import type { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js';18import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';19import type { ISession } from '../../../../services/sessions/common/session.js';20import type { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js';21import { agentSessionSettingsUri, AgentSessionSettingsFileSystemProvider, AgentSessionSettingsSchemaRegistrar } from '../../browser/agentSessionSettingsFileSystemProvider.js';2223const PROVIDER_ID = 'local-agent-host';24const RESOURCE_SCHEME = 'agent-host-copilot';25const RAW_ID = 'abc-123';2627suite('AgentSessionSettingsFileSystemProvider', () => {2829const store = ensureNoDisposablesAreLeakedInTestSuite();3031function createSession(): ISession {32const resource = URI.from({ scheme: RESOURCE_SCHEME, path: `/${RAW_ID}` });33return {34sessionId: `${PROVIDER_ID}:${resource.toString()}`,35resource,36providerId: PROVIDER_ID,37} as unknown as ISession;38}3940interface ITestHarness {41readonly fs: AgentSessionSettingsFileSystemProvider;42readonly session: ISession;43readonly uri: URI;44readonly sessionProvider: IMockAgentHostSessionsProvider;45}4647interface IMockAgentHostSessionsProvider extends IAgentHostSessionsProvider {48config: ResolveSessionConfigResult | undefined;49readonly onDidChangeSessionConfigEmitter: Emitter<string>;50readonly onDidChangeSessionsEmitter: Emitter<{ added: readonly ISession[]; removed: readonly ISession[]; changed: readonly ISession[] }>;51readonly replaceCalls: Array<{ sessionId: string; values: Record<string, unknown> }>;52}5354function createHarness(55initialConfig: ResolveSessionConfigResult | undefined,56registerProvider = true,57): ITestHarness {58const session = createSession();5960const onDidChangeSessionConfigEmitter = store.add(new Emitter<string>());61const onDidChangeSessionsEmitter = store.add(new Emitter<{ added: readonly ISession[]; removed: readonly ISession[]; changed: readonly ISession[] }>());62const replaceCalls: Array<{ sessionId: string; values: Record<string, unknown> }> = [];6364const sessionProvider: IMockAgentHostSessionsProvider = {65id: PROVIDER_ID,66config: initialConfig,67onDidChangeSessionConfigEmitter,68onDidChangeSessionsEmitter,69replaceCalls,70onDidChangeSessionConfig: onDidChangeSessionConfigEmitter.event,71onDidChangeSessions: onDidChangeSessionsEmitter.event,72getSessions: () => [session],73getSessionConfig: (_sessionId: string) => sessionProvider.config,74replaceSessionConfig: async (sessionId: string, values: Record<string, unknown>) => {75replaceCalls.push({ sessionId, values });76if (sessionProvider.config) {77sessionProvider.config = {78...sessionProvider.config,79values: { ...values },80};81}82},83setSessionConfigValue: async () => { /* unused by writeFile */ },84} as unknown as IMockAgentHostSessionsProvider;8586const onDidChangeProvidersEmitter = store.add(new Emitter<{ added: readonly ISessionsProvider[]; removed: readonly ISessionsProvider[] }>());87const providersService: ISessionsProvidersService = {88getProvider<T extends ISessionsProvider>(providerId: string): T | undefined {89if (registerProvider && providerId === PROVIDER_ID) {90return sessionProvider as unknown as T;91}92return undefined;93},94getProviders: () => registerProvider ? [sessionProvider as unknown as ISessionsProvider] : [],95onDidChangeProviders: onDidChangeProvidersEmitter.event,96} as unknown as ISessionsProvidersService;9798const instantiationService = store.add(new TestInstantiationService(new ServiceCollection(99[ISessionsProvidersService, providersService],100[ILogService, new NullLogService()],101)));102103const schemaRegistrar = store.add(instantiationService.createInstance(AgentSessionSettingsSchemaRegistrar));104const fs = store.add(instantiationService.createInstance(AgentSessionSettingsFileSystemProvider, schemaRegistrar));105106return { fs, session, uri: agentSessionSettingsUri(session), sessionProvider };107}108109test('readFile returns mutable, non-readOnly config values as JSON', async () => {110const { fs, uri } = createHarness({111schema: {112type: 'object',113properties: {114autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },115isolation: { type: 'string', title: 'Isolation', enum: ['worktree'] }, // non-mutable — omitted116branch: { type: 'string', title: 'Branch', sessionMutable: true, readOnly: true, enum: ['main'] }, // readOnly — omitted117},118},119values: { autoApprove: 'default', isolation: 'worktree', branch: 'main' },120});121122const buf = await fs.readFile(uri);123const text = VSBuffer.wrap(buf).toString();124const jsonStart = text.indexOf('{');125const parsed = JSON.parse(text.substring(jsonStart));126assert.deepStrictEqual(parsed, { autoApprove: 'default' });127});128129test('writeFile with unchanged content still forwards raw input (provider guards/short-circuits)', async () => {130const { fs, uri, session, sessionProvider } = createHarness({131schema: {132type: 'object',133properties: {134autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },135},136},137values: { autoApprove: 'default' },138});139140const current = await fs.readFile(uri);141await fs.writeFile(uri, current, { create: false, overwrite: true, unlock: false, atomic: false });142// FS provider forwards the parsed JSON as-is; the guard/short-circuit143// is the provider's responsibility (covered in the provider test).144assert.deepStrictEqual(sessionProvider.replaceCalls, [{145sessionId: session.sessionId,146values: { autoApprove: 'default' },147}]);148});149150test('writeFile forwards the user\'s parsed JSON as the replace payload', async () => {151const { fs, uri, session, sessionProvider } = createHarness({152schema: {153type: 'object',154properties: {155autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },156mode: { type: 'string', title: 'Mode', sessionMutable: true, enum: ['a', 'b'] },157isolation: { type: 'string', title: 'Isolation', enum: ['worktree'] }, // non-mutable158branch: { type: 'string', title: 'Branch', sessionMutable: true, readOnly: true, enum: ['main'] }, // readOnly159},160},161values: { autoApprove: 'default', mode: 'a', isolation: 'worktree', branch: 'main' },162});163164// User edits: only editable keys are exposed and round-tripped through165// the FS provider. Non-editable preservation is the provider's job.166const newContent = VSBuffer.fromString('// trailing comments ok\n{ "autoApprove": "autoApprove", "mode": "b", }\n').buffer;167await fs.writeFile(uri, newContent, { create: false, overwrite: true, unlock: false, atomic: false });168169assert.deepStrictEqual(sessionProvider.replaceCalls, [{170sessionId: session.sessionId,171values: { autoApprove: 'autoApprove', mode: 'b' },172}]);173});174175test('writeFile forwards a partial edit set, supporting unset via omission', async () => {176const { fs, uri, session, sessionProvider } = createHarness({177schema: {178type: 'object',179properties: {180autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },181mode: { type: 'string', title: 'Mode', sessionMutable: true, enum: ['a', 'b'] },182isolation: { type: 'string', title: 'Isolation', enum: ['worktree'] },183},184},185values: { autoApprove: 'autoApprove', mode: 'a', isolation: 'worktree' },186});187188const newContent = VSBuffer.fromString('{ "autoApprove": "default" }\n').buffer;189await fs.writeFile(uri, newContent, { create: false, overwrite: true, unlock: false, atomic: false });190191assert.deepStrictEqual(sessionProvider.replaceCalls, [{192sessionId: session.sessionId,193values: { autoApprove: 'default' },194}]);195});196197test('onDidChangeFile fires when provider config changes', async () => {198const { fs, uri, session, sessionProvider } = createHarness({199schema: { type: 'object', properties: {} },200values: {},201});202203const events: URI[] = [];204const listeners = new DisposableStore();205store.add(listeners);206listeners.add(fs.onDidChangeFile(changes => {207for (const c of changes) {208events.push(c.resource);209}210}));211const watch = fs.watch(uri, { recursive: false, excludes: [] });212listeners.add(watch);213214sessionProvider.onDidChangeSessionConfigEmitter.fire(session.sessionId);215216assert.strictEqual(events.length, 1);217assert.strictEqual(events[0].toString(), uri.toString());218});219220test('readFile on unknown provider throws FileNotFound', async () => {221const { fs, uri } = createHarness(undefined, /*registerProvider*/ false);222223await assert.rejects(async () => {224await fs.readFile(uri);225});226});227228suite('schema registration', () => {229const schemaRegistry = Registry.as<IJSONContributionRegistry>(JSONExtensions.JSONContribution);230231function expectedSchemaId(session: ISession): string {232return `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`;233}234235test('readFile lazily registers a schema + association for the session', async () => {236const { fs, uri, session } = createHarness({237schema: {238type: 'object',239properties: {240autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },241},242},243values: { autoApprove: 'default' },244});245const schemaId = expectedSchemaId(session);246247// No registration before the file is read.248assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), false);249assert.strictEqual(schemaRegistry.getSchemaAssociations()[schemaId], undefined);250251await fs.readFile(uri);252253assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), true);254assert.deepStrictEqual(schemaRegistry.getSchemaAssociations()[schemaId], [uri.toString()]);255});256257test('schema is refreshed when onDidChangeSessionConfig fires with a new schema identity', async () => {258const { fs, uri, session, sessionProvider } = createHarness({259schema: {260type: 'object',261properties: {262autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default'] },263},264},265values: { autoApprove: 'default' },266});267const schemaId = expectedSchemaId(session);268269// Trigger initial registration.270await fs.readFile(uri);271const initial = schemaRegistry.getSchemaContributions().schemas[schemaId];272assert.ok(initial);273274// Swap in a new schema (identity change) and notify.275sessionProvider.config = {276schema: {277type: 'object',278properties: {279autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },280mode: { type: 'string', title: 'Mode', sessionMutable: true, enum: ['a', 'b'] },281},282},283values: { autoApprove: 'default', mode: 'a' },284};285sessionProvider.onDidChangeSessionConfigEmitter.fire(session.sessionId);286287const refreshed = schemaRegistry.getSchemaContributions().schemas[schemaId];288assert.notStrictEqual(refreshed, initial);289assert.ok(refreshed.properties?.['mode'], 'refreshed schema should include the newly added property');290});291292test('schema is disposed when the session is removed', async () => {293const { fs, uri, session, sessionProvider } = createHarness({294schema: {295type: 'object',296properties: {297autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default'] },298},299},300values: { autoApprove: 'default' },301});302const schemaId = expectedSchemaId(session);303304await fs.readFile(uri);305assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), true);306307sessionProvider.onDidChangeSessionsEmitter.fire({ added: [], removed: [session], changed: [] });308309assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), false);310assert.strictEqual(schemaRegistry.getSchemaAssociations()[schemaId], undefined);311});312});313});314315316