SPB Git

Repository Public

schema.test.ts 11.1 KB 192 lines typescript

1import { describe, expect, it } from 'vitest';2import { z } from 'zod';3import { EffectClass, RiskClass } from '@aos/shared';4import { effectOf, isControlTool, opt, riskOf, strictSchemaProblems, TOOL_NAMES, TOOLS, toStrictJsonSchema, parseControl, finishToEnvelope, CONTROL_TOOL_NAMES } from './index.js';5import { makeFixture } from './testutil.js';67const EXPECTED = ['read_file', 'list_dir', 'search', 'edit_file', 'write_file', 'shell', 'git_status', 'git_diff', 'git_commit', 'git_push', 'ask_owner', 'propose_criteria', 'update_criteria', 'read_artifact', 'finish'];89/** Build a minimal instance from a strict schema, choosing null for every nullable (what a strict model sends for "unset"). */10function sample(schema: Record<string, unknown>): unknown {11  if (Array.isArray(schema.anyOf)) {12    const variants = schema.anyOf as Record<string, unknown>[];13    return variants.some((v) => v.type === 'null') ? null : sample(variants[0]!);14  }15  if (Array.isArray(schema.enum)) return schema.enum[0];16  switch (schema.type) {17    case 'object':18      return Object.fromEntries(Object.entries(schema.properties as Record<string, Record<string, unknown>>).map(([k, v]) => [k, sample(v)]));19    case 'array':20      return [sample(schema.items as Record<string, unknown>)];21    case 'string':22      return 'x';23    case 'integer':24    case 'number':25      return 1;26    case 'boolean':27      return true;28    default:29      return null;30  }31}3233describe('registry', () => {34  it('contains exactly the contract tools, in a stable order', () => {35    expect(TOOL_NAMES).toEqual(EXPECTED);36    for (const [name, tool] of Object.entries(TOOLS)) expect(tool.def.name).toBe(name);37  });3839  for (const name of EXPECTED) {40    it(`${name}: strict-compatible JSON Schema, description, classes`, () => {41      const tool = TOOLS[name]!;42      const s = tool.def.inputSchema;43      expect(strictSchemaProblems(s)).toEqual([]);44      expect(s.type).toBe('object');45      expect(JSON.parse(JSON.stringify(s))).toEqual(s); // plain JSON46      expect(JSON.stringify(s)).not.toMatch(/"\$schema"|"\$ref"|"default"|"minLength"|"maximum"/);47      expect(tool.def.name).toMatch(/^[a-z_]{1,64}$/);48      expect(tool.def.description.length).toBeGreaterThan(80);49      expect(tool.def.description.length).toBeLessThan(2000);50      // every property carries a description for the model, except self-explanatory nested enum/kind fields51      for (const [k, v] of Object.entries(s.properties as Record<string, Record<string, unknown>>)) {52        if (name === 'update_criteria' && k === 'updates') continue;53        expect(typeof v.description, `${name}.${k}`).toBe('string');54      }55      expect(typeof tool.parallelSafe).toBe('boolean');56      for (const network of ['none', 'open'] as const) {57        expect(EffectClass.options).toContain(effectOf(tool, {}, { network }));58        expect(RiskClass.options).toContain(riskOf(tool, {}, { network }));59      }60      // an instance generated from the JSON Schema (nulls for optionals) is accepted by the zod schema61      const parsed = tool.input.safeParse(sample(s));62      expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);63    });64  }6566  it('classes match the contract', () => {67    const net = (n: 'none' | 'open') => ({ network: n });68    for (const n of ['read_file', 'list_dir', 'search', 'git_status', 'git_diff', 'read_artifact']) {69      expect(effectOf(TOOLS[n]!, {}), n).toBe('read');70      expect(riskOf(TOOLS[n]!, {}), n).toBe('read');71      expect(TOOLS[n]!.parallelSafe, n).toBe(true);72    }73    for (const n of ['edit_file', 'write_file', 'git_commit']) {74      expect(effectOf(TOOLS[n]!, {}), n).toBe('idempotent_write');75      expect(riskOf(TOOLS[n]!, {}), n).toBe('workspace_write');76      expect(TOOLS[n]!.parallelSafe, n).toBe(false);77    }78    expect(effectOf(TOOLS.git_push!, {})).toBe('non_idempotent');79    expect(riskOf(TOOLS.git_push!, {})).toBe('external');80    expect(typeof TOOLS.git_push!.reconcile).toBe('function');81    expect(riskOf(TOOLS.shell!, {}, net('none'))).toBe('workspace_write');82    expect(riskOf(TOOLS.shell!, {}, net('open'))).toBe('network');83    expect(riskOf(TOOLS.shell!, {})).toBe('workspace_write');84    expect(effectOf(TOOLS.shell!, {}, net('none'))).toBe('idempotent_write');85    expect(effectOf(TOOLS.shell!, {}, net('open'))).toBe('non_idempotent');86    expect(TOOLS.shell!.parallelSafe).toBe(false);87    for (const n of CONTROL_TOOL_NAMES) {88      expect(effectOf(TOOLS[n]!, {})).toBe('pure');89      expect(TOOLS[n]!.parallelSafe).toBe(false);90    }91  });9293  it('describeForApproval gives the exact call for write/external tools', () => {94    expect(TOOLS.shell!.describeForApproval!({ command: 'npm test\necho done', timeoutSec: null })).toMatchObject({ title: 'Run: npm test' });95    expect(TOOLS.git_push!.describeForApproval!({ remote: null, branch: 'aos/1234' }).title).toBe('git push origin aos/1234');96    const e = TOOLS.edit_file!.describeForApproval!({ path: 'a.ts', oldString: 'a', newString: 'b', replaceAll: null });97    expect(e.diff).toContain('-a\n+b');98    expect(TOOLS.write_file!.describeForApproval!({ path: 'a.ts', content: 'x\ny' }).diff).toContain('+x\n+y');99    expect(TOOLS.git_commit!.describeForApproval!({ message: 'feat: x\n\nbody', paths: null }).title).toBe('git commit: feat: x body');100  });101});102103describe('toStrictJsonSchema', () => {104  it('makes optionals nullable + required and strips unsupported keywords', () => {105    const s = toStrictJsonSchema(106      z.object({107        a: z.string().min(2).max(5).describe('A'),108        b: opt(z.number().int().min(1)).describe('B'),109        c: z.array(z.object({ d: z.boolean().optional(), e: z.enum(['x', 'y']) })).max(3),110        f: z.string().default('zz'),111      }),112    );113    expect(s).toEqual({114      type: 'object',115      properties: {116        a: { type: 'string', description: 'A' },117        b: { description: 'B', anyOf: [{ type: 'integer' }, { type: 'null' }] },118        c: {119          type: 'array',120          items: {121            type: 'object',122            properties: { d: { anyOf: [{ type: 'boolean' }, { type: 'null' }] }, e: { type: 'string', enum: ['x', 'y'] } },123            required: ['d', 'e'],124            additionalProperties: false,125          },126        },127        f: { anyOf: [{ type: 'string' }, { type: 'null' }] },128      },129      required: ['a', 'b', 'c', 'f'],130      additionalProperties: false,131    });132    expect(strictSchemaProblems(s)).toEqual([]);133  });134  it('strictSchemaProblems detects non-strict schemas', () => {135    expect(strictSchemaProblems({ type: 'object', properties: { a: { type: 'string' } }, required: [] })).toHaveLength(2);136    expect(strictSchemaProblems({ type: 'object', properties: { a: { type: ['string', 'null'] } }, required: ['a'], additionalProperties: false }).join(' ')).toMatch(/type arrays not allowed/);137    expect(strictSchemaProblems({ type: 'object', properties: { a: { type: 'string', minLength: 1 } }, required: ['a'], additionalProperties: false })).toHaveLength(1);138  });139  it('rejects non-object roots', () => {140    expect(() => toStrictJsonSchema(z.string())).toThrow();141  });142});143144describe('control tools', () => {145  const fx = makeFixture();146  it('isControlTool', () => {147    expect(EXPECTED.filter(isControlTool)).toEqual(['ask_owner', 'propose_criteria', 'update_criteria', 'finish']);148    expect(isControlTool('shell')).toBe(false);149  });150  it('ask_owner returns a typed marker and performs nothing', async () => {151    const res = await fx.call(TOOLS.ask_owner!, { question: '  Which database?  ', options: null });152    expect(res.ok).toBe(true);153    expect(res.control).toEqual({ kind: 'ask_owner', question: 'Which database?', options: [] });154    expect(JSON.parse(res.output)).toMatchObject({ control: 'ask_owner', accepted: true });155    expect(res.trust).toBe('system');156    expect(parseControl('ask_owner', { question: 'q', options: ['a', 'b'] })).toEqual({ kind: 'ask_owner', question: 'q', options: ['a', 'b'] });157  });158  it('propose_criteria', async () => {159    const res = await fx.call(TOOLS.propose_criteria!, { criteria: ['tests pass', 'lint clean', 'README updated'], rationale: null });160    expect(res.control).toEqual({ kind: 'propose_criteria', criteria: ['tests pass', 'lint clean', 'README updated'], rationale: null });161    expect(TOOLS.propose_criteria!.input.safeParse({ criteria: [], rationale: null }).success).toBe(false);162  });163  it('update_criteria requires evidence for a pass and unique ids', async () => {164    const ok = await fx.call(TOOLS.update_criteria!, { updates: [{ id: 'c1', passes: true, evidence: 'npm test → 12 passed, exit 0' }, { id: 'c2', passes: false, evidence: '' }] });165    expect(ok.control).toEqual({ kind: 'update_criteria', updates: [{ id: 'c1', passes: true, evidence: 'npm test → 12 passed, exit 0' }, { id: 'c2', passes: false, evidence: '' }] });166    expect(TOOLS.update_criteria!.input.safeParse({ updates: [{ id: 'c1', passes: true, evidence: '  ' }] }).success).toBe(false);167    expect(TOOLS.update_criteria!.input.safeParse({ updates: [{ id: 'c1', passes: false, evidence: '' }, { id: 'c1', passes: false, evidence: '' }] }).success).toBe(false);168    expect(TOOLS.update_criteria!.input.safeParse({ updates: [{ id: 'c1', passes: true, evidence: 'e', text: 'rewritten criterion' }] }).success).toBe(true); // extra keys are dropped, never applied169    expect(parseControl('update_criteria', { updates: [{ id: 'c1', passes: true, evidence: 'e', text: 'rewritten' }] })).toEqual({ kind: 'update_criteria', updates: [{ id: 'c1', passes: true, evidence: 'e' }] });170    // run() itself never throws on bad input171    const bad = await TOOLS.update_criteria!.run({ updates: [{ id: 'c1', passes: true, evidence: '' }] }, fx.ctx);172    expect(bad.ok).toBe(false);173    expect(bad.control).toBeUndefined();174    expect(bad.output).toMatch(/evidence is required/);175  });176  it('finish: payload, envelope mapping and the reason rule', async () => {177    const done = await fx.call(TOOLS.finish!, { status: 'done', summary: 'Fixed the bug.', reason: null, evidence: [{ criterion: 'c1', kind: 'test', detail: 'npm test exit 0' }, { criterion: null, kind: 'note', detail: 'n' }], openQuestions: [] });178    expect(done.control).toEqual({179      kind: 'finish', status: 'done', summary: 'Fixed the bug.', reason: null,180      evidence: [{ criterion: 'c1', kind: 'test', detail: 'npm test exit 0' }, { kind: 'note', detail: 'n' }], openQuestions: [],181    });182    if (done.control?.kind !== 'finish') throw new Error('unreachable');183    expect(finishToEnvelope(done.control)).toEqual({ status: 'done', summary: 'Fixed the bug.', payload: null, artifacts: [], evidence: done.control.evidence, openQuestions: [] });184    expect(TOOLS.finish!.input.safeParse({ status: 'blocked', summary: 's', reason: null, evidence: [], openQuestions: [] }).success).toBe(false);185    const blocked = parseControl('finish', { status: 'blocked', summary: 's', reason: 'no credentials', evidence: [], openQuestions: ['which account?'] });186    if (blocked.kind !== 'finish') throw new Error('unreachable');187    expect(finishToEnvelope(blocked)).toMatchObject({ status: 'blocked', summary: 's\n\nReason: no credentials', openQuestions: ['which account?'] });188    expect(TOOLS.finish!.input.safeParse({ status: 'cancelled', summary: 's', reason: 'r', evidence: [], openQuestions: [] }).success).toBe(false);189    await fx.cleanup();190  });191});192
Files main
Menu

Theme