Skip to content

Commit 4378de5

Browse files
authored
fix(wizard): parse detection JSON defensively with Zod + retry
The agentic detector's extractJson did a strict JSON.parse that threw a SyntaxError on any non-JSON or truncated agent reply. In a headless monorepo run that exception propagated to scopeInstallDirToProject, whose catch only logs and returns — so the wizard silently integrated against the repo root instead of the detected sub-project. Replace the single strict parse with a Zod-validated retry loop: - run detection, locate + JSON.parse the reply, validate against a Zod schema - on a malformed reply, re-prompt the agent with the specific problems - up to 3 attempts, then fall through best-effort (coerce the last reply that parsed as JSON; empty report if none did) instead of throwing parseAgentReport never throws and is exported for testing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Generated-By: PostHog Code Task-Id: ce1f323c-14f1-4ca2-85c4-072b9c0e2afb
1 parent e1e6f5f commit 4378de5

2 files changed

Lines changed: 269 additions & 66 deletions

File tree

src/lib/detection/__tests__/agentic.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
11
import {
22
coerceAgenticReport,
33
manifestGlob,
4+
parseAgentReport,
45
resolveProjectDir,
56
} from '@lib/detection/agentic';
67

8+
const VALID_REPORT = {
9+
repoType: 'monorepo',
10+
projects: [
11+
{
12+
path: 'apps/web',
13+
framework: 'Next.js',
14+
targetId: 'nextjs',
15+
hasPostHog: false,
16+
},
17+
],
18+
};
19+
720
const TARGETS = ['nextjs', 'node', 'vite'];
821

922
describe('manifestGlob', () => {
@@ -37,6 +50,58 @@ describe('manifestGlob', () => {
3750
});
3851
});
3952

53+
describe('parseAgentReport', () => {
54+
it('accepts a well-formed report', () => {
55+
const result = parseAgentReport(JSON.stringify(VALID_REPORT));
56+
expect(result.ok).toBe(true);
57+
if (result.ok) expect(result.value).toEqual(VALID_REPORT);
58+
});
59+
60+
it('unwraps a report from a ```json code fence', () => {
61+
const result = parseAgentReport(
62+
'Sure, here it is:\n```json\n' + JSON.stringify(VALID_REPORT) + '\n```',
63+
);
64+
expect(result.ok).toBe(true);
65+
});
66+
67+
it('fails without throwing when there is no JSON object', () => {
68+
const result = parseAgentReport('I could not find anything to report.');
69+
expect(result.ok).toBe(false);
70+
if (!result.ok) {
71+
expect(result.feedback).toMatch(/no json object/i);
72+
expect(result.parsedJson).toBeUndefined();
73+
}
74+
});
75+
76+
it('fails with feedback (not a throw) on malformed JSON', () => {
77+
// Has braces (so a candidate is extracted) but is not valid JSON.
78+
const result = parseAgentReport('{"repoType": "single", "projects": [ }');
79+
expect(result.ok).toBe(false);
80+
if (!result.ok) {
81+
expect(result.feedback).toMatch(/not valid json/i);
82+
expect(result.parsedJson).toBeUndefined();
83+
}
84+
});
85+
86+
it('reports per-field problems but keeps the parsed JSON for best-effort coercion', () => {
87+
// Shape-invalid: repoType is wrong and hasPostHog is missing.
88+
const parsedButInvalid = {
89+
repoType: 'workspace',
90+
projects: [{ path: 'apps/web', framework: 'Next.js', targetId: null }],
91+
};
92+
const result = parseAgentReport(JSON.stringify(parsedButInvalid));
93+
expect(result.ok).toBe(false);
94+
if (!result.ok) {
95+
expect(result.feedback).toMatch(/required shape/i);
96+
// parsedJson survives so the caller can still coerce it after retries.
97+
expect(result.parsedJson).toEqual(parsedButInvalid);
98+
expect(coerceAgenticReport(result.parsedJson, ['nextjs']).repoType).toBe(
99+
'single',
100+
);
101+
}
102+
});
103+
});
104+
40105
describe('coerceAgenticReport', () => {
41106
it('keeps a targetId that is in the valid set', () => {
42107
const report = coerceAgenticReport(

src/lib/detection/agentic.ts

Lines changed: 204 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
AgentSignals,
2020
} from '@lib/agent/agent-interface';
2121
import { isAbsolute, resolve, sep } from 'path';
22+
import { z } from 'zod';
2223
import { detectNodePackageManagers } from './package-manager.js';
2324
import { getSkillsBaseUrl, HAIKU_MODEL } from '@lib/constants';
2425
import type { WizardSession } from '@lib/wizard-session';
@@ -165,15 +166,113 @@ function buildPrompt(
165166
].join('\n');
166167
}
167168

168-
function extractJson(text: string): unknown {
169+
/**
170+
* Zod shape of the agent's RAW detection JSON. Validated before coercion so a
171+
* malformed reply produces actionable, per-field feedback we can hand back to
172+
* the agent for a retry — the security clamping (paths, targetIds, recommended
173+
* dedup) still happens afterwards in coerceAgenticReport.
174+
*/
175+
const AgenticProjectJson = z.object({
176+
path: z.string(),
177+
framework: z.string(),
178+
targetId: z.string().nullable(),
179+
hasPostHog: z.boolean(),
180+
recommended: z.boolean().optional(),
181+
});
182+
const AgenticReportJson = z.object({
183+
repoType: z.enum(['monorepo', 'single']),
184+
projects: z.array(AgenticProjectJson),
185+
});
186+
187+
/** How many times the agent is asked to produce a valid report before we give up and fall through best-effort. */
188+
const MAX_DETECTION_ATTEMPTS = 3;
189+
190+
type ParseReport =
191+
| { ok: true; value: unknown }
192+
/**
193+
* `feedback` is the human-readable reason the reply was rejected, fed back
194+
* into the next attempt. `parsedJson` is set whenever JSON.parse itself
195+
* succeeded (only the schema failed), so the caller can still best-effort
196+
* coerce a shape-invalid-but-parseable reply once retries run out.
197+
*/
198+
| { ok: false; feedback: string; parsedJson?: unknown };
199+
200+
/**
201+
* Locate the JSON object in the agent's output, parse it, and validate it
202+
* against the report schema. NEVER throws — a non-JSON, truncated, or off-shape
203+
* reply comes back as `{ ok: false }` with feedback, so the caller drives the
204+
* retry loop instead of a strict JSON.parse crashing the whole detection.
205+
* Exported for testing.
206+
*/
207+
export function parseAgentReport(text: string): ParseReport {
169208
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
170209
const candidate = fenced ? fenced[1] : text;
171210
const start = candidate.indexOf('{');
172211
const end = candidate.lastIndexOf('}');
173212
if (start === -1 || end === -1 || end < start) {
174-
throw new Error('Agent did not return a JSON object');
213+
return {
214+
ok: false,
215+
feedback:
216+
'No JSON object was found in your response. Respond with ONLY a single JSON object.',
217+
};
175218
}
176-
return JSON.parse(candidate.slice(start, end + 1));
219+
220+
let parsedJson: unknown;
221+
try {
222+
parsedJson = JSON.parse(candidate.slice(start, end + 1));
223+
} catch (err) {
224+
return {
225+
ok: false,
226+
feedback: `Your response was not valid JSON (${
227+
err instanceof Error ? err.message : String(err)
228+
}). Respond with ONLY a single, complete JSON object — no trailing commas, no truncation.`,
229+
};
230+
}
231+
232+
const validated = AgenticReportJson.safeParse(parsedJson);
233+
if (!validated.success) {
234+
const issues = validated.error.issues
235+
.slice(0, 10)
236+
.map((i) => `- ${i.path.join('.') || '(root)'}: ${i.message}`)
237+
.join('\n');
238+
return {
239+
ok: false,
240+
parsedJson,
241+
feedback: `Your JSON did not match the required shape. Fix these problems:\n${issues}`,
242+
};
243+
}
244+
245+
return { ok: true, value: validated.data };
246+
}
247+
248+
/**
249+
* Wrap the original task with corrective feedback for a retry. Each attempt is
250+
* a fresh agent conversation, so we restate the failure, show a snippet of the
251+
* rejected reply, and re-attach the full task prompt.
252+
*/
253+
function buildRetryPrompt(
254+
basePrompt: string,
255+
feedback: string,
256+
previousOutput: string,
257+
attempt: number,
258+
): string {
259+
const snippet = previousOutput.trim().slice(0, 600);
260+
return [
261+
`Your previous detection response could not be used (attempt ${
262+
attempt - 1
263+
} of ${MAX_DETECTION_ATTEMPTS}).`,
264+
'',
265+
'What was wrong:',
266+
feedback,
267+
'',
268+
...(snippet
269+
? ['Your previous response began with:', '"""', snippet, '"""', '']
270+
: []),
271+
'Run the detection again and respond with ONLY a single valid JSON object matching the required shape exactly — no prose, no markdown code fences.',
272+
'',
273+
'--- Original task ---',
274+
basePrompt,
275+
].join('\n');
177276
}
178277

179278
/**
@@ -299,79 +398,118 @@ export async function detectProjectsWithAgent(
299398
runOptions,
300399
);
301400

302-
// Keeps only the transcript tail — the report JSON is the last output.
303-
const MAX_TRANSCRIPT_CHARS = 256 * 1024;
304-
const collected: string[] = [];
305-
let collectedChars = 0;
306-
const collect = (text: string): void => {
307-
collected.push(text);
308-
collectedChars += text.length;
309-
while (collectedChars > MAX_TRANSCRIPT_CHARS && collected.length > 1) {
310-
collectedChars -= collected.shift()!.length;
311-
}
312-
};
313-
let resultText = '';
314-
315-
const middleware = {
316-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
317-
onMessage: (message: any): void => {
318-
if (message?.type === 'assistant') {
319-
for (const block of message.message?.content ?? []) {
320-
if (block?.type === 'text' && typeof block.text === 'string') {
321-
collect(block.text);
322-
const line = block.text.trim();
323-
if (line && onEvent) {
324-
onEvent(line.length > 100 ? `${line.slice(0, 100)}…` : line);
401+
// One agent conversation: drive the loop, collect the transcript tail (the
402+
// report JSON is the last output), and return whatever text it produced.
403+
const runDetectionAttempt = async (
404+
attemptPrompt: string,
405+
): Promise<string> => {
406+
const MAX_TRANSCRIPT_CHARS = 256 * 1024;
407+
const collected: string[] = [];
408+
let collectedChars = 0;
409+
const collect = (text: string): void => {
410+
collected.push(text);
411+
collectedChars += text.length;
412+
while (collectedChars > MAX_TRANSCRIPT_CHARS && collected.length > 1) {
413+
collectedChars -= collected.shift()!.length;
414+
}
415+
};
416+
let resultText = '';
417+
418+
const middleware = {
419+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
420+
onMessage: (message: any): void => {
421+
if (message?.type === 'assistant') {
422+
for (const block of message.message?.content ?? []) {
423+
if (block?.type === 'text' && typeof block.text === 'string') {
424+
collect(block.text);
425+
const line = block.text.trim();
426+
if (line && onEvent) {
427+
onEvent(line.length > 100 ? `${line.slice(0, 100)}…` : line);
428+
}
429+
} else if (block?.type === 'tool_use') {
430+
onEvent?.(formatToolUse(block));
325431
}
326-
} else if (block?.type === 'tool_use') {
327-
onEvent?.(formatToolUse(block));
328432
}
433+
} else if (
434+
message?.type === 'result' &&
435+
typeof message.result === 'string'
436+
) {
437+
resultText = message.result;
329438
}
330-
} else if (
331-
message?.type === 'result' &&
332-
typeof message.result === 'string'
333-
) {
334-
resultText = message.result;
335-
}
336-
},
337-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
338-
finalize: (_resultMessage: any, _durationMs: number): unknown => undefined,
439+
},
440+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
441+
finalize: (_resultMessage: any, _durationMs: number): unknown =>
442+
undefined,
443+
};
444+
445+
const result = await executeAgent(
446+
agent,
447+
attemptPrompt,
448+
runOptions,
449+
NOOP_SPINNER,
450+
{
451+
spinnerMessage: 'Scanning the repo...',
452+
successMessage: 'Detection complete',
453+
errorMessage: 'Detection failed',
454+
requestRemark: false,
455+
},
456+
middleware,
457+
);
458+
459+
if (result.error) {
460+
throw new Error(result.message || `Agent error: ${result.error}`);
461+
}
462+
463+
return resultText || collected.join('\n');
339464
};
340465

341-
const result = await executeAgent(
342-
agent,
343-
buildPrompt(cwd, targets, purpose, recommend),
344-
runOptions,
345-
NOOP_SPINNER,
346-
{
347-
spinnerMessage: 'Scanning the repo...',
348-
successMessage: 'Detection complete',
349-
errorMessage: 'Detection failed',
350-
requestRemark: false,
351-
},
352-
middleware,
353-
);
466+
const basePrompt = buildPrompt(cwd, targets, purpose, recommend);
467+
const validTargetIds = targets.map((t) => t.id);
354468

355-
if (result.error) {
356-
throw new Error(result.message || `Agent error: ${result.error}`);
357-
}
469+
// Run detection, validate the reply with Zod, and on a malformed reply
470+
// re-prompt the agent with the specific problems — up to MAX_DETECTION_ATTEMPTS
471+
// times. The last reply that at least parsed as JSON is kept so we can
472+
// best-effort coerce it if every attempt fails validation.
473+
let prompt = basePrompt;
474+
let lastParsedJson: unknown = undefined;
475+
476+
for (let attempt = 1; attempt <= MAX_DETECTION_ATTEMPTS; attempt++) {
477+
const output = await runDetectionAttempt(prompt);
358478

359-
const output = resultText || collected.join('\n');
360-
try {
361-
return coerceAgenticReport(
362-
extractJson(output),
363-
targets.map((t) => t.id),
364-
{ recommend },
365-
);
366-
} catch (err) {
367479
// The prompt tells the agent to emit `[ABORT] detection failed` when the
368-
// repo has no recognizable project manifests. Surface that (and any other
369-
// non-JSON terminal output that carries the abort signal) as an empty
370-
// report so the screen renders a friendly "nothing to instrument" state
371-
// instead of a cryptic "Agent did not return a JSON object" error.
480+
// repo has no recognizable manifests. Surface that as an empty report so
481+
// the screen renders a friendly "nothing to instrument" state.
372482
if (output.includes(AgentSignals.ABORT)) {
373483
return { repoType: 'single', projects: [] };
374484
}
375-
throw err;
485+
486+
const parsed = parseAgentReport(output);
487+
if (parsed.ok) {
488+
return coerceAgenticReport(parsed.value, validTargetIds, { recommend });
489+
}
490+
491+
if (parsed.parsedJson !== undefined) lastParsedJson = parsed.parsedJson;
492+
493+
if (attempt < MAX_DETECTION_ATTEMPTS) {
494+
onEvent?.(
495+
`Detection response was malformed; retrying (${
496+
attempt + 1
497+
}/${MAX_DETECTION_ATTEMPTS})…`,
498+
);
499+
prompt = buildRetryPrompt(
500+
basePrompt,
501+
parsed.feedback,
502+
output,
503+
attempt + 1,
504+
);
505+
}
376506
}
507+
508+
// Retries exhausted. Best effort: coerce the last reply that at least parsed
509+
// as JSON (coerceAgenticReport clamps/defaults every field, and treats
510+
// `undefined` as an empty report). Returning an empty report rather than
511+
// throwing means a headless monorepo run scopes to the install dir as-is
512+
// instead of crashing on a SyntaxError and silently mis-targeting the root.
513+
onEvent?.('Detection did not return a valid report; continuing best-effort.');
514+
return coerceAgenticReport(lastParsedJson, validTargetIds, { recommend });
377515
}

0 commit comments

Comments
 (0)