Repository Public
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