Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { VSBuffer } from '../../../../../base/common/buffer.js';
8
import { Emitter } from '../../../../../base/common/event.js';
9
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
10
import { URI } from '../../../../../base/common/uri.js';
11
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
12
import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js';
13
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
14
import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';
15
import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js';
16
import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../../platform/jsonschemas/common/jsonContributionRegistry.js';
17
import { Registry } from '../../../../../platform/registry/common/platform.js';
18
import type { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js';
19
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
20
import type { ISession } from '../../../../services/sessions/common/session.js';
21
import type { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js';
22
import { agentSessionSettingsUri, AgentSessionSettingsFileSystemProvider, AgentSessionSettingsSchemaRegistrar } from '../../browser/agentSessionSettingsFileSystemProvider.js';
23
24
const PROVIDER_ID = 'local-agent-host';
25
const RESOURCE_SCHEME = 'agent-host-copilot';
26
const RAW_ID = 'abc-123';
27
28
suite('AgentSessionSettingsFileSystemProvider', () => {
29
30
const store = ensureNoDisposablesAreLeakedInTestSuite();
31
32
function createSession(): ISession {
33
const resource = URI.from({ scheme: RESOURCE_SCHEME, path: `/${RAW_ID}` });
34
return {
35
sessionId: `${PROVIDER_ID}:${resource.toString()}`,
36
resource,
37
providerId: PROVIDER_ID,
38
} as unknown as ISession;
39
}
40
41
interface ITestHarness {
42
readonly fs: AgentSessionSettingsFileSystemProvider;
43
readonly session: ISession;
44
readonly uri: URI;
45
readonly sessionProvider: IMockAgentHostSessionsProvider;
46
}
47
48
interface IMockAgentHostSessionsProvider extends IAgentHostSessionsProvider {
49
config: ResolveSessionConfigResult | undefined;
50
readonly onDidChangeSessionConfigEmitter: Emitter<string>;
51
readonly onDidChangeSessionsEmitter: Emitter<{ added: readonly ISession[]; removed: readonly ISession[]; changed: readonly ISession[] }>;
52
readonly replaceCalls: Array<{ sessionId: string; values: Record<string, unknown> }>;
53
}
54
55
function createHarness(
56
initialConfig: ResolveSessionConfigResult | undefined,
57
registerProvider = true,
58
): ITestHarness {
59
const session = createSession();
60
61
const onDidChangeSessionConfigEmitter = store.add(new Emitter<string>());
62
const onDidChangeSessionsEmitter = store.add(new Emitter<{ added: readonly ISession[]; removed: readonly ISession[]; changed: readonly ISession[] }>());
63
const replaceCalls: Array<{ sessionId: string; values: Record<string, unknown> }> = [];
64
65
const sessionProvider: IMockAgentHostSessionsProvider = {
66
id: PROVIDER_ID,
67
config: initialConfig,
68
onDidChangeSessionConfigEmitter,
69
onDidChangeSessionsEmitter,
70
replaceCalls,
71
onDidChangeSessionConfig: onDidChangeSessionConfigEmitter.event,
72
onDidChangeSessions: onDidChangeSessionsEmitter.event,
73
getSessions: () => [session],
74
getSessionConfig: (_sessionId: string) => sessionProvider.config,
75
replaceSessionConfig: async (sessionId: string, values: Record<string, unknown>) => {
76
replaceCalls.push({ sessionId, values });
77
if (sessionProvider.config) {
78
sessionProvider.config = {
79
...sessionProvider.config,
80
values: { ...values },
81
};
82
}
83
},
84
setSessionConfigValue: async () => { /* unused by writeFile */ },
85
} as unknown as IMockAgentHostSessionsProvider;
86
87
const onDidChangeProvidersEmitter = store.add(new Emitter<{ added: readonly ISessionsProvider[]; removed: readonly ISessionsProvider[] }>());
88
const providersService: ISessionsProvidersService = {
89
getProvider<T extends ISessionsProvider>(providerId: string): T | undefined {
90
if (registerProvider && providerId === PROVIDER_ID) {
91
return sessionProvider as unknown as T;
92
}
93
return undefined;
94
},
95
getProviders: () => registerProvider ? [sessionProvider as unknown as ISessionsProvider] : [],
96
onDidChangeProviders: onDidChangeProvidersEmitter.event,
97
} as unknown as ISessionsProvidersService;
98
99
const instantiationService = store.add(new TestInstantiationService(new ServiceCollection(
100
[ISessionsProvidersService, providersService],
101
[ILogService, new NullLogService()],
102
)));
103
104
const schemaRegistrar = store.add(instantiationService.createInstance(AgentSessionSettingsSchemaRegistrar));
105
const fs = store.add(instantiationService.createInstance(AgentSessionSettingsFileSystemProvider, schemaRegistrar));
106
107
return { fs, session, uri: agentSessionSettingsUri(session), sessionProvider };
108
}
109
110
test('readFile returns mutable, non-readOnly config values as JSON', async () => {
111
const { fs, uri } = createHarness({
112
schema: {
113
type: 'object',
114
properties: {
115
autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },
116
isolation: { type: 'string', title: 'Isolation', enum: ['worktree'] }, // non-mutable — omitted
117
branch: { type: 'string', title: 'Branch', sessionMutable: true, readOnly: true, enum: ['main'] }, // readOnly — omitted
118
},
119
},
120
values: { autoApprove: 'default', isolation: 'worktree', branch: 'main' },
121
});
122
123
const buf = await fs.readFile(uri);
124
const text = VSBuffer.wrap(buf).toString();
125
const jsonStart = text.indexOf('{');
126
const parsed = JSON.parse(text.substring(jsonStart));
127
assert.deepStrictEqual(parsed, { autoApprove: 'default' });
128
});
129
130
test('writeFile with unchanged content still forwards raw input (provider guards/short-circuits)', async () => {
131
const { fs, uri, session, sessionProvider } = createHarness({
132
schema: {
133
type: 'object',
134
properties: {
135
autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },
136
},
137
},
138
values: { autoApprove: 'default' },
139
});
140
141
const current = await fs.readFile(uri);
142
await fs.writeFile(uri, current, { create: false, overwrite: true, unlock: false, atomic: false });
143
// FS provider forwards the parsed JSON as-is; the guard/short-circuit
144
// is the provider's responsibility (covered in the provider test).
145
assert.deepStrictEqual(sessionProvider.replaceCalls, [{
146
sessionId: session.sessionId,
147
values: { autoApprove: 'default' },
148
}]);
149
});
150
151
test('writeFile forwards the user\'s parsed JSON as the replace payload', async () => {
152
const { fs, uri, session, sessionProvider } = createHarness({
153
schema: {
154
type: 'object',
155
properties: {
156
autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },
157
mode: { type: 'string', title: 'Mode', sessionMutable: true, enum: ['a', 'b'] },
158
isolation: { type: 'string', title: 'Isolation', enum: ['worktree'] }, // non-mutable
159
branch: { type: 'string', title: 'Branch', sessionMutable: true, readOnly: true, enum: ['main'] }, // readOnly
160
},
161
},
162
values: { autoApprove: 'default', mode: 'a', isolation: 'worktree', branch: 'main' },
163
});
164
165
// User edits: only editable keys are exposed and round-tripped through
166
// the FS provider. Non-editable preservation is the provider's job.
167
const newContent = VSBuffer.fromString('// trailing comments ok\n{ "autoApprove": "autoApprove", "mode": "b", }\n').buffer;
168
await fs.writeFile(uri, newContent, { create: false, overwrite: true, unlock: false, atomic: false });
169
170
assert.deepStrictEqual(sessionProvider.replaceCalls, [{
171
sessionId: session.sessionId,
172
values: { autoApprove: 'autoApprove', mode: 'b' },
173
}]);
174
});
175
176
test('writeFile forwards a partial edit set, supporting unset via omission', async () => {
177
const { fs, uri, session, sessionProvider } = createHarness({
178
schema: {
179
type: 'object',
180
properties: {
181
autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },
182
mode: { type: 'string', title: 'Mode', sessionMutable: true, enum: ['a', 'b'] },
183
isolation: { type: 'string', title: 'Isolation', enum: ['worktree'] },
184
},
185
},
186
values: { autoApprove: 'autoApprove', mode: 'a', isolation: 'worktree' },
187
});
188
189
const newContent = VSBuffer.fromString('{ "autoApprove": "default" }\n').buffer;
190
await fs.writeFile(uri, newContent, { create: false, overwrite: true, unlock: false, atomic: false });
191
192
assert.deepStrictEqual(sessionProvider.replaceCalls, [{
193
sessionId: session.sessionId,
194
values: { autoApprove: 'default' },
195
}]);
196
});
197
198
test('onDidChangeFile fires when provider config changes', async () => {
199
const { fs, uri, session, sessionProvider } = createHarness({
200
schema: { type: 'object', properties: {} },
201
values: {},
202
});
203
204
const events: URI[] = [];
205
const listeners = new DisposableStore();
206
store.add(listeners);
207
listeners.add(fs.onDidChangeFile(changes => {
208
for (const c of changes) {
209
events.push(c.resource);
210
}
211
}));
212
const watch = fs.watch(uri, { recursive: false, excludes: [] });
213
listeners.add(watch);
214
215
sessionProvider.onDidChangeSessionConfigEmitter.fire(session.sessionId);
216
217
assert.strictEqual(events.length, 1);
218
assert.strictEqual(events[0].toString(), uri.toString());
219
});
220
221
test('readFile on unknown provider throws FileNotFound', async () => {
222
const { fs, uri } = createHarness(undefined, /*registerProvider*/ false);
223
224
await assert.rejects(async () => {
225
await fs.readFile(uri);
226
});
227
});
228
229
suite('schema registration', () => {
230
const schemaRegistry = Registry.as<IJSONContributionRegistry>(JSONExtensions.JSONContribution);
231
232
function expectedSchemaId(session: ISession): string {
233
return `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`;
234
}
235
236
test('readFile lazily registers a schema + association for the session', async () => {
237
const { fs, uri, session } = createHarness({
238
schema: {
239
type: 'object',
240
properties: {
241
autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },
242
},
243
},
244
values: { autoApprove: 'default' },
245
});
246
const schemaId = expectedSchemaId(session);
247
248
// No registration before the file is read.
249
assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), false);
250
assert.strictEqual(schemaRegistry.getSchemaAssociations()[schemaId], undefined);
251
252
await fs.readFile(uri);
253
254
assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), true);
255
assert.deepStrictEqual(schemaRegistry.getSchemaAssociations()[schemaId], [uri.toString()]);
256
});
257
258
test('schema is refreshed when onDidChangeSessionConfig fires with a new schema identity', async () => {
259
const { fs, uri, session, sessionProvider } = createHarness({
260
schema: {
261
type: 'object',
262
properties: {
263
autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default'] },
264
},
265
},
266
values: { autoApprove: 'default' },
267
});
268
const schemaId = expectedSchemaId(session);
269
270
// Trigger initial registration.
271
await fs.readFile(uri);
272
const initial = schemaRegistry.getSchemaContributions().schemas[schemaId];
273
assert.ok(initial);
274
275
// Swap in a new schema (identity change) and notify.
276
sessionProvider.config = {
277
schema: {
278
type: 'object',
279
properties: {
280
autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] },
281
mode: { type: 'string', title: 'Mode', sessionMutable: true, enum: ['a', 'b'] },
282
},
283
},
284
values: { autoApprove: 'default', mode: 'a' },
285
};
286
sessionProvider.onDidChangeSessionConfigEmitter.fire(session.sessionId);
287
288
const refreshed = schemaRegistry.getSchemaContributions().schemas[schemaId];
289
assert.notStrictEqual(refreshed, initial);
290
assert.ok(refreshed.properties?.['mode'], 'refreshed schema should include the newly added property');
291
});
292
293
test('schema is disposed when the session is removed', async () => {
294
const { fs, uri, session, sessionProvider } = createHarness({
295
schema: {
296
type: 'object',
297
properties: {
298
autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default'] },
299
},
300
},
301
values: { autoApprove: 'default' },
302
});
303
const schemaId = expectedSchemaId(session);
304
305
await fs.readFile(uri);
306
assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), true);
307
308
sessionProvider.onDidChangeSessionsEmitter.fire({ added: [], removed: [session], changed: [] });
309
310
assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), false);
311
assert.strictEqual(schemaRegistry.getSchemaAssociations()[schemaId], undefined);
312
});
313
});
314
});
315
316