From 35069fe4ad6bffa7398c2a8f3b3e6a2bb07cca47 Mon Sep 17 00:00:00 2001 From: lakshit verma Date: Wed, 19 Aug 2026 22:05:20 +0530 Subject: [PATCH] fix(backend): parse triage proposals wrapped in prose or code fences Free-model routers often answer with surrounding prose, so a strict whole- response JSON parse failed and every triage run ended in error. Extract the first JSON block before parsing, keeping the zod schema gate intact. --- backend/src/agent/triage.ts | 22 +++++++++++++++++----- backend/tests/triage.worker.test.ts | 12 ++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/backend/src/agent/triage.ts b/backend/src/agent/triage.ts index c0e81c0..315c3b3 100644 --- a/backend/src/agent/triage.ts +++ b/backend/src/agent/triage.ts @@ -197,14 +197,26 @@ function buildUserContent(wo: WorkOrderDoc, lastError: string | null): string { type ProposalParse = { ok: true; data: TriageProposal } | { ok: false; message: string }; +function extractJsonBlock(text: string): string | null { + const trimmed = text.trim(); + const fenced = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim(); + for (const candidate of [fenced, trimmed]) { + const start = candidate.search(/[{[]/); + if (start === -1) continue; + const open = candidate[start] as '{' | '['; + const close = open === '{' ? '}' : ']'; + const end = candidate.lastIndexOf(close); + if (end > start) return candidate.slice(start, end + 1); + } + return null; +} + function parseProposal(content: string): ProposalParse { - const cleaned = content - .trim() - .replace(/^```(?:json)?\s*/i, '') - .replace(/```\s*$/, ''); + const block = extractJsonBlock(content); + if (!block) return { ok: false, message: 'the response did not contain a JSON object' }; let obj: unknown; try { - obj = JSON.parse(cleaned); + obj = JSON.parse(block); } catch { return { ok: false, message: 'the response was not valid JSON' }; } diff --git a/backend/tests/triage.worker.test.ts b/backend/tests/triage.worker.test.ts index 027489e..727af52 100644 --- a/backend/tests/triage.worker.test.ts +++ b/backend/tests/triage.worker.test.ts @@ -151,6 +151,18 @@ describe('triage agent', () => { expect(providerMock.chatComplete).not.toHaveBeenCalled(); }); + it('parses a proposal wrapped in prose and code fences', async () => { + const { wo } = await makeWorkOrder(); + providerMock.chatComplete.mockResolvedValue( + providerResult({ content: `Sure, here you go:\n\`\`\`json\n${proposal()}\n\`\`\`\nHope that helps!` }), + ); + const outcome = await runTriage(wo.id); + expect(outcome.outcome).toBe('done'); + const suggestions = await TriageSuggestion.find({ workOrderId: wo.id }); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ suggestedPriority: 'high' }); + }); + it('returns retry when the provider fails transiently and records AI_UNAVAILABLE', async () => { const { wo } = await makeWorkOrder(); providerMock.chatComplete.mockRejectedValue(new providerMock.ProviderError('upstream down', 'network'));