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
2 changes: 2 additions & 0 deletions apps/api/src/services/aiGuardrails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ export const TOOL_PERMISSIONS: Record<string, { resource: string; action: string
resume: { resource: 'contracts', action: 'manage' },
cancel: { resource: 'contracts', action: 'manage' },
},
list_quotes: { resource: 'quotes', action: 'read' },
get_quote: { resource: 'quotes', action: 'read' },
manage_quotes: {
create_draft: { resource: 'quotes', action: 'write' },
update: { resource: 'quotes', action: 'write' },
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/services/aiToolSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ export const toolInputSchemas: Record<string, z.ZodType> = {
payment: z.record(z.string(), z.unknown()).optional(),
}),

list_quotes: z.object({
orgId: uuid.optional(),
status: z.enum(['draft', 'sent', 'viewed', 'accepted', 'declined', 'expired', 'converted']).optional(),
limit: z.number().int().min(1).max(100).optional(),
}),

get_quote: z.object({
quoteId: uuid,
}),

manage_quotes: z.object({
action: z.enum([
'create_draft',
Expand Down
85 changes: 82 additions & 3 deletions apps/api/src/services/aiToolsQuotes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('./quoteService', () => ({
createQuote: vi.fn().mockResolvedValue({ id: 'quote-1', status: 'draft' }),
listQuotes: vi.fn().mockResolvedValue([
{ id: 'quote-1', quoteNumber: 'Q-2026-0001', status: 'draft' },
{ id: 'quote-2', quoteNumber: 'Q-2026-0002', status: 'sent' },
]),
updateQuote: vi.fn().mockResolvedValue({ id: 'quote-1', introNotes: 'Updated' }),
getQuote: vi.fn().mockResolvedValue({
quote: {
Expand Down Expand Up @@ -76,11 +80,11 @@ const SITE_UUID = '22222222-2222-4222-8222-222222222222';
const CATALOG_UUID = '33333333-3333-4333-8333-333333333333';
const BLOCK_UUID = '44444444-4444-4444-8444-444444444444';

function getTool(): AiTool {
function getTool(name = 'manage_quotes'): AiTool {
const map = new Map<string, AiTool>();
registerQuoteTools(map);
const t = map.get('manage_quotes');
if (!t) throw new Error('manage_quotes not registered');
const t = map.get(name);
if (!t) throw new Error(`${name} not registered`);
return t;
}

Expand Down Expand Up @@ -344,3 +348,78 @@ describe('manage_quotes input validation (#2362)', () => {
expect(quoteService.removeLine).not.toHaveBeenCalled();
});
});

describe('list_quotes / get_quote read tools (#2361)', () => {
beforeEach(() => vi.clearAllMocks());

it('list_quotes with no filters lists quotes with the default limit', async () => {
const out = await getTool('list_quotes').handler({}, auth);

expect(quoteService.listQuotes).toHaveBeenCalledWith(
expect.objectContaining({ limit: 25 }),
actor,
);
const parsed = JSON.parse(out);
expect(parsed.showing).toBe(2);
expect(parsed.quotes).toHaveLength(2);
expect(parsed.quotes[0].id).toBe('quote-1');
});

it('list_quotes forwards org/status filters and clamps limit via the shared schema', async () => {
await getTool('list_quotes').handler({ orgId: ORG_UUID, status: 'sent', limit: 10 }, auth);

expect(quoteService.listQuotes).toHaveBeenCalledWith(
expect.objectContaining({ orgId: ORG_UUID, status: 'sent', limit: 10 }),
actor,
);
});

it('list_quotes with an invalid status returns a structured VALIDATION_ERROR', async () => {
const out = await getTool('list_quotes').handler({ status: 'bogus' }, auth);

const parsed = JSON.parse(out);
expect(parsed.code).toBe('VALIDATION_ERROR');
expect(parsed.error).toContain('status');
expect(quoteService.listQuotes).not.toHaveBeenCalled();
});

it('get_quote returns the full view (header + blocks + lines) from getQuote', async () => {
const out = await getTool('get_quote').handler({ quoteId: 'quote-1' }, auth);

expect(quoteService.getQuote).toHaveBeenCalledWith('quote-1', actor);
const parsed = JSON.parse(out);
expect(parsed.quote.id).toBe('quote-1');
expect(parsed).toHaveProperty('blocks');
expect(parsed).toHaveProperty('lines');
});

it('get_quote without quoteId returns a structured VALIDATION_ERROR', async () => {
const out = await getTool('get_quote').handler({}, auth);

const parsed = JSON.parse(out);
expect(parsed.code).toBe('VALIDATION_ERROR');
expect(parsed.error).toContain('quoteId');
expect(quoteService.getQuote).not.toHaveBeenCalled();
});

it('get_quote maps QuoteServiceError (e.g. QUOTE_NOT_FOUND) to a structured error', async () => {
vi.mocked(quoteService.getQuote).mockRejectedValueOnce(
new QuoteServiceError('Quote not found', 404, 'QUOTE_NOT_FOUND'),
);

const out = await getTool('get_quote').handler({ quoteId: 'missing' }, auth);

expect(JSON.parse(out)).toEqual({ error: 'Quote not found', code: 'QUOTE_NOT_FOUND' });
});

it('update with an empty patch is rejected with VALIDATION_ERROR and does not touch the quote (#2361)', async () => {
const out = await getTool().handler({ action: 'update', quoteId: 'quote-1', patch: {} }, auth);

const parsed = JSON.parse(out);
expect(parsed.code).toBe('VALIDATION_ERROR');
expect(parsed.error).toContain('get_quote');
// No UPDATE runs, so updatedAt cannot be bumped by an empty patch.
expect(quoteService.updateQuote).not.toHaveBeenCalled();
expect(quoteService.getQuote).not.toHaveBeenCalled();
});
});
98 changes: 94 additions & 4 deletions apps/api/src/services/aiToolsQuotes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
/**
* AI Quote/Proposal Tools
*
* AI write tool over the quote engine:
* AI tools over the quote engine:
* - `list_quotes` — list quotes for the caller's accessible orgs, with
* optional org/status filters, newest first (mirrors `list_contracts`).
* - `get_quote` — full view (header with derived totals + blocks + lines)
* for one quote, reusing the same `getQuote` service the web UI reads
* (mirrors `get_contract`).
* - `manage_quotes` — action multiplexer for quote draft edits, proposal
* blocks, lines, lifecycle send/decline, and pay links.
*
Expand Down Expand Up @@ -31,13 +36,15 @@ import {
catalogQuoteLineSchema,
reorderBlocksSchema,
reorderLinesSchema,
listQuotesQuerySchema,
} from '@breeze/shared';
import type { AuthContext } from '../middleware/auth';
import type { AiTool, AiToolTier } from './aiTools';
import { missingParamsJson, zodErrorToJson } from './aiToolValidation';
import { missingParamsJson, validationErrorJson, zodErrorToJson } from './aiToolValidation';
import {
createQuote,
getQuote,
listQuotes,
updateQuote,
deleteDraftQuote,
addBlock,
Expand Down Expand Up @@ -108,6 +115,77 @@ const linePayload = z.object({ line: quoteLineInputSchema });
const linePatchPayload = z.object({ patch: updateQuoteLineSchema });

export function registerQuoteTools(aiTools: Map<string, AiTool>): void {
aiTools.set('list_quotes', {
tier: 2 as AiToolTier,
deviceArgs: [],
definition: {
name: 'list_quotes',
description:
'List quotes/proposals for the orgs the caller can access, newest first. Optionally filter by org or status. Read-only.',
input_schema: {
type: 'object' as const,
properties: {
orgId: { type: 'string', description: 'Filter to a single organization (UUID)' },
status: {
type: 'string',
enum: ['draft', 'sent', 'viewed', 'accepted', 'declined', 'expired', 'converted'],
description: 'Filter by quote status'
},
limit: { type: 'number', description: 'Max results (default 25, max 100)' }
},
required: []
}
},
handler: async (input, auth) => {
try {
// Same schema the GET /quotes route validates with (status enum, limit
// bounds); an out-of-range/unknown filter returns a structured
// VALIDATION_ERROR via zodErrorToJson instead of throwing.
const query = listQuotesQuerySchema.parse({
orgId: input.orgId ?? undefined,
status: input.status ?? undefined,
limit: input.limit ?? 25,
});
const rows = await listQuotes(query, actorFromAuth(auth));
return JSON.stringify({ quotes: rows, showing: rows.length });
} catch (err) {
const json = serviceErrorToJson(err) ?? zodErrorToJson(err);
if (json) return json;
throw err;
}
}
});

aiTools.set('get_quote', {
tier: 2 as AiToolTier,
deviceArgs: [],
definition: {
name: 'get_quote',
description:
'Get the full view of one quote/proposal by id: header (with derived totals, deposit and category ' +
'breakdown), content blocks, and line items — the same view the web UI shows. Read-only.',
input_schema: {
type: 'object' as const,
properties: {
quoteId: { type: 'string', description: 'Quote UUID' }
},
required: ['quoteId']
}
},
handler: async (input, auth) => {
if (input.quoteId == null) {
return validationErrorJson('Missing required parameter: quoteId');
}
try {
return JSON.stringify(await getQuote(String(input.quoteId), actorFromAuth(auth)));
} catch (err) {
const json = serviceErrorToJson(err) ?? zodErrorToJson(err);
if (json) return json;
throw err;
}
}
});

aiTools.set('manage_quotes', {
tier: 2 as AiToolTier,
deviceArgs: [],
Expand All @@ -116,6 +194,7 @@ export function registerQuoteTools(aiTools: Map<string, AiTool>): void {
description:
'Create and manage quotes/proposals for orgs the caller can access: draft header edits, blocks, lines, ' +
'send/decline lifecycle actions, and accepted-quote pay links. Sending a quote requires approval. ' +
'Read-only access: use list_quotes / get_quote. ' +
'Required params per action — create_draft: input; update: quoteId, patch; delete_draft/send/decline/' +
'create_pay_link: quoteId; add_block: quoteId, block; update_block: quoteId, blockId, block; delete_block: ' +
'quoteId, blockId; reorder_blocks: quoteId, blockIds; add_manual_line: quoteId, line; add_catalog_line: ' +
Expand Down Expand Up @@ -227,12 +306,23 @@ export function registerQuoteTools(aiTools: Map<string, AiTool>): void {
case 'update': {
const quoteId = String(input.quoteId);
const { patch } = headerPatchPayload.parse({ patch: input.patch });
// Reject an empty patch instead of running a no-field UPDATE: it
// used to be the only "read" workaround (#2361) and silently bumped
// updatedAt. Now that get_quote exists, point callers at it.
if (Object.values(patch).every((v) => v === undefined)) {
// Note: unknown keys are stripped by updateQuoteSchema first, so a
// patch of only unrecognized/line-level fields lands here too.
return validationErrorJson(
'patch contains no updatable header fields — nothing to update. ' +
'Use get_quote to read a quote; use update_line for line fields.'
);
}
await updateQuote(quoteId, patch, actor);
// Re-read rather than return updateQuote's raw row: the raw row carries
// depositType/depositPercent/depositAmount but not the derived
// depositDueTotal/categoryBreakdown (computed from current lines in
// getQuote) — there's no separate read tool for the AI to fetch those,
// so the update response has to surface them itself.
// getQuote) — surfacing them in the update response saves the model
// a follow-up get_quote call.
const { quote } = await getQuote(quoteId, actor);
return JSON.stringify(quote);
}
Expand Down
Loading