managarten/packages/shared-ai/src/tools/function-schema.test.ts
Till JS a4bc7d2ee3 feat(invoices): M8 AI tools — create/mark_paid/list/stats
The last open item from the plan. Missions can now draft invoices from
chat context, mark customer payments, and read status for autonomous
follow-up cadences.

Tool catalog (packages/shared-ai/src/tools/schemas.ts)
- create_invoice (propose) — clientName + lines[] + currency + due
- mark_invoice_paid (propose) — by id, optional back-dated paidAt
- list_invoices (auto) — with status + limit filter
- get_invoice_stats (auto) — open/overdue/YTD per currency

Had to widen the tool-parameter type vocabulary so create_invoice can
declare lines as a typed array. Touched three places:
- ToolSchema-side: the catalog's `type` string is already free-form so
  'array' / 'object' just pass through
- ModuleTool-side (apps/mana/apps/web/src/lib/data/tools/types.ts): added
  'array' | 'object' to the union so TS doesn't narrow the executor's
  param signatures
- function-schema translator (packages/shared-ai): mapParamType +
  JsonSchemaProperty both gained the two new types; the catalog-typo
  guard test now uses 'fruit' as its sentinel (array no longer unknown)

Executor (apps/mana/apps/web/src/lib/modules/invoices/tools.ts)
- coerceLines accepts either a real array or a JSON-stringified array
  (planners vary), skips malformed entries, converts major→minor units
- create_invoice pulls the generated number back from Dexie so the
  success message shows "Entwurf 2026-0042 …" — the user recognises it
- mark_invoice_paid normalises YYYY-MM-DD → ISO so the store's timestamp
  invariant (ISO throughout) stays intact
- list_invoices derives overdue on read (consistent with useAllInvoices),
  returns major-unit amounts so the LLM reasons in user-facing numbers
- get_invoice_stats returns counts + open/overdue/YTD per currency

Registration: invoicesTools added to tools/init.ts. mana-ai drift guard
is happy (41/41 green); webapp + shared-ai type-check 0 errors; full
invoice test suite 59/59 green.

Closes: docs/plans/invoices-module.md §M8. All plan milestones now DONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:22:20 +02:00

116 lines
3.5 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { AI_TOOL_CATALOG } from './schemas';
import { toolToFunctionSchema, toolsToFunctionSchemas } from './function-schema';
import type { ToolSchema } from './schemas';
describe('toolToFunctionSchema', () => {
it('converts a minimal tool with no parameters', () => {
const tool: ToolSchema = {
name: 'ping',
module: 'test',
description: 'Test tool',
defaultPolicy: 'auto',
parameters: [],
};
expect(toolToFunctionSchema(tool)).toEqual({
type: 'function',
function: {
name: 'ping',
description: 'Test tool',
parameters: {
type: 'object',
properties: {},
required: [],
},
},
});
});
it('maps parameter types and marks required fields', () => {
const tool: ToolSchema = {
name: 'create_thing',
module: 'test',
description: 'Create a thing',
defaultPolicy: 'propose',
parameters: [
{ name: 'title', type: 'string', description: 'Title', required: true },
{ name: 'count', type: 'number', description: 'Count', required: false },
{ name: 'enabled', type: 'boolean', description: 'Flag', required: false },
],
};
const schema = toolToFunctionSchema(tool);
expect(schema.function.parameters.properties.title).toEqual({
type: 'string',
description: 'Title',
});
expect(schema.function.parameters.properties.count.type).toBe('number');
expect(schema.function.parameters.properties.enabled.type).toBe('boolean');
expect(schema.function.parameters.required).toEqual(['title']);
});
it('preserves enum when present', () => {
const tool: ToolSchema = {
name: 'pick',
module: 'test',
description: 'Pick one',
defaultPolicy: 'auto',
parameters: [
{
name: 'color',
type: 'string',
description: 'Color',
required: true,
enum: ['red', 'blue', 'green'],
},
],
};
const schema = toolToFunctionSchema(tool);
expect(schema.function.parameters.properties.color.enum).toEqual(['red', 'blue', 'green']);
});
it('does not add an enum key when the parameter has none', () => {
const tool: ToolSchema = {
name: 'x',
module: 'test',
description: 'X',
defaultPolicy: 'auto',
parameters: [{ name: 'y', type: 'string', description: 'Y', required: true }],
};
const prop = toolToFunctionSchema(tool).function.parameters.properties.y;
expect('enum' in prop).toBe(false);
});
it('throws on unknown parameter types (catalog typo guard)', () => {
const tool: ToolSchema = {
name: 'broken',
module: 'test',
description: 'Broken',
defaultPolicy: 'auto',
parameters: [
// `fruit` is genuinely not a JSON Schema type — the catalog-typo
// guard exists to catch accidental one-off strings like this.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ name: 'p', type: 'fruit' as any, description: 'p', required: true },
],
};
expect(() => toolToFunctionSchema(tool)).toThrow(/Unsupported parameter type/);
});
});
describe('toolsToFunctionSchemas', () => {
it('round-trips the whole catalog without throwing', () => {
const schemas = toolsToFunctionSchemas(AI_TOOL_CATALOG);
expect(schemas.length).toBe(AI_TOOL_CATALOG.length);
for (const s of schemas) {
expect(s.type).toBe('function');
expect(s.function.name).toMatch(/^[a-z_]+$/);
expect(s.function.parameters.type).toBe('object');
}
});
it('produces globally unique function names (matches catalog)', () => {
const schemas = toolsToFunctionSchemas(AI_TOOL_CATALOG);
const names = schemas.map((s) => s.function.name);
expect(new Set(names).size).toBe(names.length);
});
});