Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions apps/agent-forge/server/author/draft/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest';
import {
authorDraftSchema,
authorPatchSchema,
DRAFT_OMITTED_KEYS,
FIELD_DESCRIPTIONS,
NULLABLE_PATCH_KEYS,
} from '../schema';

/**
* The draft/patch zod contracts of the Author's propose tools: which keys
* a draft must, may and must not carry; the patch's optional/nullable
* rules; and the `.describe()` coverage the model-facing JSON Schema
* depends on (a spec key added without a FIELD_DESCRIPTIONS row fails
* here, which is the point - the model only ever sees descriptions).
*/

const MINIMAL_DRAFT = {
name: 'Support triage',
description: 'Routes support mail to the right queue.',
model: 'anthropic/claude-sonnet-5-5',
instructions: 'You triage support mail and pick the queue.',
};

describe('authorDraftSchema', () => {
it('accepts a minimal valid draft', () => {
const parsed = authorDraftSchema.safeParse(MINIMAL_DRAFT);
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data).toEqual(MINIMAL_DRAFT);
}
});

it.each(['name', 'description', 'model', 'instructions'])('requires %s', (key) => {
const draft = { ...MINIMAL_DRAFT } as Record<string, unknown>;
delete draft[key];
expect(authorDraftSchema.safeParse(draft).success).toBe(false);
});

it.each([...DRAFT_OMITTED_KEYS, 'labels', 'bogusField'])('rejects the never-settable key %s', (key) => {
expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, [key]: 'x' }).success).toBe(false);
});

it('requires description, capped at 500 characters', () => {
expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, description: '' }).success).toBe(false);
expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, description: 'x'.repeat(501) }).success).toBe(false);
expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, description: 'x'.repeat(500) }).success).toBe(true);
});

it('takes instructions as a string only, never { file }', () => {
expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, instructions: { file: 'prompt.md' } }).success).toBe(false);
expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, instructions: '' }).success).toBe(false);
});

it('validates fields with the spec rules (model id shape, name shape)', () => {
expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, model: 'claude' }).success).toBe(false);
expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, name: '!!!' }).success).toBe(false);
});
});

describe('authorPatchSchema', () => {
it('accepts an empty patch and every draft key on its own', () => {
expect(authorPatchSchema.safeParse({}).success).toBe(true);
for (const key of Object.keys(authorDraftSchema.shape)) {
const patch: Record<string, unknown> = {};
patch[key] = undefined;
expect(authorPatchSchema.safeParse(patch).success).toBe(true);
}
});

it.each(['version', 'evals', 'flow', 'bogusField'])('rejects the unknown key %s', (key) => {
expect(authorPatchSchema.safeParse({ [key]: 'x' }).success).toBe(false);
});

it('keeps null in the parsed patch so the apply step can reset the field', () => {
const parsed = authorPatchSchema.safeParse({ reasoning: null });
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.reasoning).toBeNull();
});

it('accepts null on every spec-optional field except description', () => {
for (const key of NULLABLE_PATCH_KEYS.filter((key) => key in authorDraftSchema.shape)) {
expect(authorPatchSchema.safeParse({ [key]: null }).success).toBe(true);
}
});

it.each(['description', 'name', 'model', 'instructions'])('rejects null on %s', (key) => {
expect(authorPatchSchema.safeParse({ [key]: null }).success).toBe(false);
});

it('still validates a provided value (clearing a list, replacing a field)', () => {
expect(authorPatchSchema.safeParse({ tools: [] }).success).toBe(true);
expect(authorPatchSchema.safeParse({ tools: 'many' }).success).toBe(false);
expect(authorPatchSchema.safeParse({ model: 'claude' }).success).toBe(false);
});
});

describe('NULLABLE_PATCH_KEYS', () => {
it('covers the spec-optional keys a reset makes sense for', () => {
for (const key of ['reasoning', 'tools', 'triggers', 'mcpServers', 'promptCaching', 'fallbackModels']) {
expect(NULLABLE_PATCH_KEYS).toContain(key);
}
});

it('excludes description and the required fields', () => {
for (const key of ['description', 'name', 'model', 'instructions', 'version']) {
expect(NULLABLE_PATCH_KEYS).not.toContain(key);
}
});
});

describe('describe coverage', () => {
it('FIELD_DESCRIPTIONS covers exactly the draft keys', () => {
expect(Object.keys(FIELD_DESCRIPTIONS).sort()).toEqual(Object.keys(authorDraftSchema.shape).sort());
});

it('authorPatchSchema has the same keys as the draft schema', () => {
expect(Object.keys(authorPatchSchema.shape).sort()).toEqual(Object.keys(authorDraftSchema.shape).sort());
});

it.each(Object.keys(authorDraftSchema.shape))('draft field %s is described', (key) => {
expect(authorDraftSchema.shape[key]?.description).toBe(FIELD_DESCRIPTIONS[key]);
});

it.each(Object.keys(authorPatchSchema.shape))('patch field %s is described', (key) => {
expect(authorPatchSchema.shape[key]?.description).toBe(FIELD_DESCRIPTIONS[key]);
});
});
204 changes: 204 additions & 0 deletions apps/agent-forge/server/author/draft/__tests__/toSpec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { describe, expect, it } from 'vitest';
import type { AgentSpecV2 } from 'lousho';
import { isValidAgentId } from '../../../../shared/agentId';
import type { AuthorDraft, AuthorPatch } from '../schema';
import { applyAuthorPatch, deriveAgentId, draftInvalidFailure, draftToSpec } from '../toSpec';

/**
* The draft → spec build rules: `{ version: 2, ...draft }` plus the
* chat-trigger backstop for creates; the keep / replace / clear / reset
* patch semantics for edits; the DRAFT_INVALID failure copy; and the
* name → agent-id derivation.
*/

const DRAFT: AuthorDraft = {
name: 'Support triage',
description: 'Routes support mail to the right queue.',
model: 'anthropic/claude-sonnet-5-5',
instructions: 'You triage support mail and pick the queue.',
};

const BASE: AgentSpecV2 = {
version: 2,
name: 'Support triage',
description: 'Routes support mail to the right queue.',
model: 'anthropic/claude-sonnet-5-5',
instructions: 'You triage support mail and pick the queue.',
tools: [{ builtin: 'web_fetch' }],
reasoning: 'low',
triggers: [{ id: 't1', type: 'chat' }],
};

describe('draftToSpec', () => {
it('builds a v2 spec and appends a chat trigger', () => {
const result = draftToSpec(DRAFT);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.spec.version).toBe(2);
expect(result.spec.name).toBe('Support triage');
expect(result.spec.triggers).toEqual([{ id: 't1', type: 'chat' }]);
}
});

it('keeps a chat trigger the draft already has, exactly once', () => {
const result = draftToSpec({ ...DRAFT, triggers: [{ id: 't1', type: 'chat' }] });
expect(result.ok).toBe(true);
if (result.ok) expect(result.spec.triggers).toEqual([{ id: 't1', type: 'chat' }]);
});

it('appends the chat trigger with the first unused t<N> id', () => {
const result = draftToSpec({
...DRAFT,
triggers: [{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Weekly sweep.' }],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.spec.triggers).toEqual([
{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Weekly sweep.' },
{ id: 't2', type: 'chat' },
]);
}
});

it("does not mutate the draft's triggers list when appending the chat trigger", () => {
const triggers: AuthorDraft['triggers'] = [{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Weekly sweep.' }];
const draft: AuthorDraft = { ...DRAFT, triggers };
const result = draftToSpec(draft);
expect(result.ok).toBe(true);
expect(draft.triggers).toBe(triggers);
expect(draft.triggers).toEqual([{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Weekly sweep.' }]);
});

it('reports issues as path: message with [n] array indexes', () => {
const result = draftToSpec({
...DRAFT,
guardrails: [{ id: 'g1', name: 'not-a-guardrail' as never, on: 'output' }],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues.some((issue) => issue.path === 'guardrails[0].name')).toBe(true);
}
});
});

describe('applyAuthorPatch', () => {
const apply = (patch: AuthorPatch) => applyAuthorPatch(BASE, patch);

it('{} keeps the base unchanged (the chat trigger stays)', () => {
const result = apply({});
expect(result.ok).toBe(true);
if (result.ok) expect(result.spec).toEqual(BASE);
});

it('a provided list replaces the list wholesale', () => {
const result = apply({ tools: [{ builtin: 'todo' }, { builtin: 'day_name' }] });
expect(result.ok).toBe(true);
if (result.ok) expect(result.spec.tools).toEqual([{ builtin: 'todo' }, { builtin: 'day_name' }]);
});

it('[] clears a list', () => {
const result = apply({ tools: [] });
expect(result.ok).toBe(true);
if (result.ok) expect(result.spec.tools).toEqual([]);
});

it('null resets an optional field to default (the key is gone)', () => {
const result = apply({ reasoning: null });
expect(result.ok).toBe(true);
if (result.ok) expect('reasoning' in result.spec).toBe(false);
});

it('a provided object replaces the object', () => {
const result = apply({ modelSettings: { temperature: 0.5 } });
expect(result.ok).toBe(true);
if (result.ok) expect(result.spec.modelSettings).toEqual({ temperature: 0.5 });
});

it('null on triggers deletes the list; the chat-trigger backstop re-adds it', () => {
const result = apply({ triggers: null });
expect(result.ok).toBe(true);
if (result.ok) expect(result.spec.triggers).toEqual([{ id: 't1', type: 'chat' }]);
});

it('adds a chat trigger when the base somehow has none', () => {
const result = applyAuthorPatch({ ...BASE, triggers: undefined }, {});
expect(result.ok).toBe(true);
if (result.ok) expect(result.spec.triggers).toEqual([{ id: 't1', type: 'chat' }]);
});

it('uses the first unused t<N> when other trigger ids exist', () => {
const result = applyAuthorPatch({ ...BASE, triggers: [{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Sweep.' }] }, {});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.spec.triggers?.[result.spec.triggers.length - 1]).toEqual({ id: 't2', type: 'chat' });
}
});

it("does not mutate the base's triggers list when appending the chat trigger", () => {
const triggers: AgentSpecV2['triggers'] = [{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Sweep.' }];
const base: AgentSpecV2 = { ...BASE, triggers };
const result = applyAuthorPatch(base, {});
expect(result.ok).toBe(true);
expect(base.triggers).toBe(triggers);
expect(base.triggers).toEqual([{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Sweep.' }]);
});

it('reports spec issues with [n] paths', () => {
const result = apply({ guardrails: [{ id: 'g1', name: 'not-a-guardrail' as never }] });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues.some((issue) => issue.path === 'guardrails[0].name')).toBe(true);
}
});
});

describe('draftInvalidFailure', () => {
it('returns the DRAFT_INVALID failure with the contract copy', () => {
const failure = draftInvalidFailure('proposeAgent', [
{ path: 'model', message: 'is required' },
{ path: 'guardrails[0].name', message: "unknown guardrail 'x'" },
]);
expect(failure).toEqual({
ok: false,
code: 'DRAFT_INVALID',
error: "The draft does not match AgentSpec v2: model: is required; guardrails[0].name: unknown guardrail 'x'.",
userMessage: 'The draft had a few invalid settings. The Author is fixing them.',
next: 'Fix the fields listed and call proposeAgent again. Use listTools, listGuardrails and listTriggerTypes for valid values.',
});
});

it('lists at most ten issues', () => {
const issues = Array.from({ length: 12 }, (_, i) => ({ path: `f${i}`, message: 'bad' }));
const failure = draftInvalidFailure('proposeAgentUpdate', issues);
expect(failure.error).toContain('f9: bad');
expect(failure.error).not.toContain('f10');
expect(failure.next).toContain('call proposeAgentUpdate again');
});
});

describe('deriveAgentId', () => {
const cases: Array<[string, ReadonlySet<string>, string]> = [
['Support Triage!', new Set(), 'support-triage'],
['Support Triage!', new Set(['support-triage']), 'support-triage-2'],
['Support Triage!', new Set(['support-triage', 'support-triage-2']), 'support-triage-3'],
['', new Set(), 'agent'],
['!!!', new Set(), 'agent'],
['', new Set(['agent']), 'agent-2'],
['Ünïcödé Ægent', new Set(), 'n-c-d-gent'],
['x'.repeat(60), new Set(), 'x'.repeat(48)],
['--lead and trail--', new Set(), 'lead-and-trail'],
['123 Numbers', new Set(), '123-numbers'],
];

it.each(cases)('deriveAgentId(%j) -> %s', (name, taken, expected) => {
expect(deriveAgentId(name, taken)).toBe(expected);
});

it('every derived id passes isValidAgentId', () => {
for (const [name, taken] of cases) {
const id = deriveAgentId(name, taken as ReadonlySet<string>);
expect(isValidAgentId(id)).toBe(true);
expect(isValidAgentId(deriveAgentId(name, new Set([...(taken as ReadonlySet<string>), id])))).toBe(true);
}
});
});
Loading
Loading