diff --git a/apps/api/src/services/aiGuardrails.ts b/apps/api/src/services/aiGuardrails.ts index a52e1ccb9a..9944fd2177 100644 --- a/apps/api/src/services/aiGuardrails.ts +++ b/apps/api/src/services/aiGuardrails.ts @@ -183,6 +183,8 @@ export const TOOL_PERMISSIONS: Record = { 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', diff --git a/apps/api/src/services/aiToolsQuotes.test.ts b/apps/api/src/services/aiToolsQuotes.test.ts index e1803aa6c4..667439944b 100644 --- a/apps/api/src/services/aiToolsQuotes.test.ts +++ b/apps/api/src/services/aiToolsQuotes.test.ts @@ -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: { @@ -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(); 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; } @@ -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(); + }); +}); diff --git a/apps/api/src/services/aiToolsQuotes.ts b/apps/api/src/services/aiToolsQuotes.ts index e57649603a..89eb7b2b6c 100644 --- a/apps/api/src/services/aiToolsQuotes.ts +++ b/apps/api/src/services/aiToolsQuotes.ts @@ -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. * @@ -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, @@ -108,6 +115,77 @@ const linePayload = z.object({ line: quoteLineInputSchema }); const linePatchPayload = z.object({ patch: updateQuoteLineSchema }); export function registerQuoteTools(aiTools: Map): 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: [], @@ -116,6 +194,7 @@ export function registerQuoteTools(aiTools: Map): 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: ' + @@ -227,12 +306,23 @@ export function registerQuoteTools(aiTools: Map): 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); }