Path: blob/main/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts
13399 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';7import { createSchema, platformSessionSchema, schemaProperty, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js';8import { SessionConfigKey } from '../../common/sessionConfigKeys.js';9import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js';1011/**12* Invokes `fn` and returns the thrown {@link ProtocolError}. Avoids13* passing an arrow-function validator to `assert.throws` — the unit-test14* assert shim does `actual instanceof expected` with that validator, and15* arrow functions have no `prototype` property, which WebKit rejects.16*/17function captureProtocolError(fn: () => void): ProtocolError {18try {19fn();20} catch (err) {21assert.ok(err instanceof ProtocolError, `expected ProtocolError, got: ${err}`);22return err;23}24assert.fail('expected fn to throw, but it did not');25}2627suite('agentHostSchema', () => {2829ensureNoDisposablesAreLeakedInTestSuite();3031// ---- schemaProperty / individual validators ---------------------------3233suite('schemaProperty', () => {3435test('validates primitive types', () => {36const str = schemaProperty<string>({ type: 'string', title: 's' });37assert.strictEqual(str.validate('hello'), true);38assert.strictEqual(str.validate(42), false);39assert.strictEqual(str.validate(undefined), false);40assert.strictEqual(str.validate(null), false);4142const num = schemaProperty<number>({ type: 'number', title: 'n' });43assert.strictEqual(num.validate(42), true);44assert.strictEqual(num.validate('42'), false);4546const bool = schemaProperty<boolean>({ type: 'boolean', title: 'b' });47assert.strictEqual(bool.validate(true), true);48assert.strictEqual(bool.validate(0), false);49});5051test('enforces enum values', () => {52const prop = schemaProperty<'a' | 'b'>({53type: 'string',54title: 'letters',55enum: ['a', 'b'],56});57assert.strictEqual(prop.validate('a'), true);58assert.strictEqual(prop.validate('b'), true);59assert.strictEqual(prop.validate('c'), false);60assert.strictEqual(prop.validate(42), false);61});6263test('enumDynamic bypasses enum check but keeps type check', () => {64const prop = schemaProperty<string>({65type: 'string',66title: 'dyn',67enum: ['seed'],68enumDynamic: true,69});70assert.strictEqual(prop.validate('seed'), true);71assert.strictEqual(prop.validate('anything-else'), true);72assert.strictEqual(prop.validate(42), false);73});7475test('validates nested objects and required keys', () => {76const prop = schemaProperty<{ name: string; age?: number }>({77type: 'object',78title: 'person',79properties: {80name: { type: 'string', title: 'name' },81age: { type: 'number', title: 'age' },82},83required: ['name'],84});85assert.strictEqual(prop.validate({ name: 'alice' }), true);86assert.strictEqual(prop.validate({ name: 'alice', age: 30 }), true);87assert.strictEqual(prop.validate({ age: 30 }), false);88assert.strictEqual(prop.validate({ name: 42 }), false);89assert.strictEqual(prop.validate([]), false);90assert.strictEqual(prop.validate(null), false);91});9293test('validates arrays with item schema', () => {94const prop = schemaProperty<string[]>({95type: 'array',96title: 'names',97items: { type: 'string', title: 'name' },98});99assert.strictEqual(prop.validate(['a', 'b']), true);100assert.strictEqual(prop.validate([]), true);101assert.strictEqual(prop.validate(['a', 42]), false);102assert.strictEqual(prop.validate('a'), false);103});104105test('assertValid throws ProtocolError with offending path for primitive mismatch', () => {106const prop = schemaProperty<string>({ type: 'string', title: 's' });107const err = captureProtocolError(() => prop.assertValid(42, 'myKey'));108assert.strictEqual(err.code, JsonRpcErrorCodes.InvalidParams);109assert.ok(err.message.includes('myKey'), err.message);110assert.ok(err.message.includes('string'), err.message);111});112113test('assertValid path annotates array index and nested property', () => {114const prop = schemaProperty<{ allow: string[] }>({115type: 'object',116title: 'perms',117properties: {118allow: {119type: 'array',120title: 'allow',121items: { type: 'string', title: 'name' },122},123},124});125const err = captureProtocolError(() => prop.assertValid({ allow: ['ok', 42] }, 'permissions'));126assert.ok(err.message.includes('permissions.allow[1]'), err.message);127assert.ok(err.message.includes('string'), err.message);128});129130test('assertValid path reports missing required property', () => {131const prop = schemaProperty<{ name: string }>({132type: 'object',133title: 'person',134properties: { name: { type: 'string', title: 'name' } },135required: ['name'],136});137const err = captureProtocolError(() => prop.assertValid({}, 'person'));138assert.ok(err.message.includes('person.name'), err.message);139assert.ok(err.message.toLowerCase().includes('required'), err.message);140});141142test('assertValid reports enum violation with the allowed set', () => {143const prop = schemaProperty<'a' | 'b'>({144type: 'string',145title: 'letters',146enum: ['a', 'b'],147});148const err = captureProtocolError(() => prop.assertValid('c', 'choice'));149assert.ok(err.message.includes('choice'), err.message);150assert.ok(err.message.includes('"a"'), err.message);151assert.ok(err.message.includes('"b"'), err.message);152});153});154155// ---- createSchema ------------------------------------------------------156157suite('createSchema', () => {158159const fixture = () => createSchema({160name: schemaProperty<string>({ type: 'string', title: 'name' }),161count: schemaProperty<number>({ type: 'number', title: 'count' }),162level: schemaProperty<'low' | 'high'>({163type: 'string',164title: 'level',165enum: ['low', 'high'],166}),167});168169test('toProtocol emits a JSON-Schema-compatible object', () => {170const schema = fixture();171const protocol = schema.toProtocol();172assert.strictEqual(protocol.type, 'object');173assert.deepStrictEqual(Object.keys(protocol.properties), ['name', 'count', 'level']);174assert.strictEqual(protocol.properties.name.type, 'string');175assert.deepStrictEqual(protocol.properties.level.enum, ['low', 'high']);176});177178test('validate returns false for unknown keys', () => {179const schema = fixture();180assert.strictEqual(schema.validate('name', 'ok'), true);181assert.strictEqual(schema.validate('name', 42), false);182assert.strictEqual(schema.validate('unknown' as 'name', 'ok'), false);183});184185test('assertValid throws for unknown keys', () => {186const schema = fixture();187const err = captureProtocolError(() => schema.assertValid('unknown' as 'name', 'x'));188assert.ok(err.message.includes('unknown'), err.message);189});190191test('values returns a shallow copy and passes through unknown keys', () => {192const schema = fixture();193const input = { name: 'alice', count: 3, extra: 'forward-compat' };194const out = schema.values(input);195assert.notStrictEqual(out, input);196assert.deepStrictEqual(out, input);197});198199test('values skips undefined entries without throwing', () => {200const schema = fixture();201const out = schema.values({ name: 'alice' });202assert.deepStrictEqual(out, { name: 'alice' });203});204205test('values throws a path-annotated ProtocolError on invalid entry', () => {206const schema = fixture();207const err = captureProtocolError(() => schema.values({ name: 42 as unknown as string }));208assert.strictEqual(err.code, JsonRpcErrorCodes.InvalidParams);209assert.ok(err.message.includes('name'), err.message);210});211212test('definition is preserved for spread-based composition', () => {213const base = createSchema({214a: schemaProperty<string>({ type: 'string', title: 'a' }),215});216const extended = createSchema({217...base.definition,218b: schemaProperty<number>({ type: 'number', title: 'b' }),219});220assert.deepStrictEqual(Object.keys(extended.toProtocol().properties), ['a', 'b']);221assert.strictEqual(extended.validate('a', 'hi'), true);222assert.strictEqual(extended.validate('b', 3), true);223});224});225226// ---- validateOrDefault -------------------------------------------------227228suite('validateOrDefault', () => {229230const fixture = () => createSchema({231name: schemaProperty<string>({ type: 'string', title: 'name' }),232count: schemaProperty<number>({ type: 'number', title: 'count' }),233});234235test('substitutes defaults for missing or invalid values', () => {236const schema = fixture();237const defaults = { name: 'default', count: 0 };238const result = schema.validateOrDefault({ name: 42, count: 5 }, defaults);239assert.deepStrictEqual(result, { name: 'default', count: 5 });240});241242test('passes through all-valid values', () => {243const schema = fixture();244const result = schema.validateOrDefault({ name: 'alice', count: 3 }, { name: 'd', count: 0 });245assert.deepStrictEqual(result, { name: 'alice', count: 3 });246});247248test('uses defaults when input is undefined', () => {249const schema = fixture();250const result = schema.validateOrDefault(undefined, { name: 'd', count: 7 });251assert.deepStrictEqual(result, { name: 'd', count: 7 });252});253254test('ignores keys not in defaults', () => {255const schema = fixture();256// @ts-expect-error: test that extra keys not in the defaults are ignored, even if they pass validation.257const result = schema.validateOrDefault({ name: 'a', count: 1, ignored: true }, { name: 'd', count: 0 });258assert.deepStrictEqual(result, { name: 'a', count: 1 });259});260261test('omits schema keys that are missing from both values and defaults', () => {262// Regression coverage for the partial-defaults contract that263// underpins host-level inheritance: if the caller doesn't supply264// a default and no incoming value is valid, the key is left out265// entirely so higher-scope defaults can fill in.266const schema = fixture();267const result = schema.validateOrDefault({ count: 9 }, { count: 0 });268assert.deepStrictEqual(result, { count: 9 });269assert.ok(!result.hasOwnProperty('name'), '`name` should be absent when neither values nor defaults supply it');270});271272test('omits schema keys when value is invalid and no default is supplied', () => {273const schema = fixture();274// @ts-expect-error: test that invalid values are dropped even when the caller doesn't provide a default.275const result = schema.validateOrDefault({ name: 42, count: 3 }, { count: 0 });276// `name` has no default and the incoming value is invalid → dropped.277assert.deepStrictEqual(result, { count: 3 });278});279});280281// ---- platformSessionSchema sanity --------------------------------------282283suite('platformSessionSchema', () => {284285test('validates the three autoApprove levels', () => {286const levels: AutoApproveLevel[] = ['default', 'autoApprove', 'autopilot'];287for (const level of levels) {288assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.AutoApprove, level), true, level);289}290assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.AutoApprove, 'bogus'), false);291});292293test('validates permissions shape', () => {294const ok: IPermissionsValue = { allow: ['read'], deny: [] };295assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, ok), true);296assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, { allow: [42], deny: [] }), false);297assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, { allow: [] }), true);298});299300test('validates the agent modes', () => {301const modes: SessionMode[] = ['interactive', 'plan'];302for (const mode of modes) {303assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, mode), true, mode);304}305// `autopilot` is intentionally NOT in the AHP mode enum \u2014 it's306// modeled on the orthogonal `autoApprove` axis instead.307assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, 'autopilot'), false);308assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, 'shell'), false);309assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, 42), false);310});311});312});313314315