diff --git a/CHANGELOG.md b/CHANGELOG.md index ade2f91f..4f3449ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Agentic adapters now use SDK-native structured output (same `detectCapabilities()` path as the file-by-file pipeline) instead of heuristic JSON text parsing. `AnthropicAdapter` passes `outputFormat: {type: "json_schema"}`, `OpenAIAdapter` uses `outputType`, and `ConfigurableAgentAdapter` uses `output: {structured: true}` — all gated on `isUnstructured()`. The text fallback path is kept for unstructured models and now restores the pre-QUALOPS-18 trailing-comma fix. Runs that return non-empty non-JSON output now throw instead of silently producing zero findings. +- `--diff-filter` parameter in `listChangedFiles` agentic tool now validated against the allowed git diff-filter character set (`[ACDMRTUXB*]`), consistent with how `base`/`head` refs are validated via `isSafeGitRef`. + ## [0.2.6] - 2026-06-17 ### Added diff --git a/package-lock.json b/package-lock.json index 4e9ae580..f06e2928 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.6", "license": "MIT", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.178", + "@anthropic-ai/claude-agent-sdk": "^0.3.179", "@anthropic-ai/sdk": "^0.104.2", "@aws-sdk/client-bedrock-runtime": "^3.1008.0", "@eggai/configurable-agent": "^0.2.1", @@ -217,22 +217,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.178.tgz", - "integrity": "sha512-PNsz20jWuahDWq7OU+pjrgYzr2TnpC1oj5yCZDxGWJ8OvucXIdD2AYlf/vUo7oE2JJwGbMTBFqXErNrfPi6Ffw==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.179.tgz", + "integrity": "sha512-BlZULVW61ZCcho6mb026QoOQbGZnfxbsEtcoNaW5lF0sIEu7D+/nnQjHSMK8buS/h7HVmIx2RtGbHVX1y4V0uQ==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.178", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.178", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.178", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.178", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.178", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.178", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.178", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.178" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.179", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.179", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.179", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.179", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.179", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.179", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.179", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.179" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -241,9 +241,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.178.tgz", - "integrity": "sha512-RmIJRoZfjwcrd7cHR3fJOL1d4L8SN7oB0REcQPbIuPM1vav2Ft3g5hBX7I86u52A4LLFKBc3SMom3+E6lR1YtQ==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.179.tgz", + "integrity": "sha512-2QoEl7p+RTkF4ARZ40N9OWWi+at8Iy9ZZg3UeP6sWoGrM0GL6NJSv8pbYUdoSRySbNeZshqWar5DF41265J5Sg==", "cpu": [ "arm64" ], @@ -254,9 +254,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.178.tgz", - "integrity": "sha512-wfNl7JoaUk9IzKWQPr+hyEOX8qnkM+e4GBZnKISh60xVhW9wOOp5RdfnYj5MoJzaLYivMSCbo5rb4ThWguhOMw==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.179.tgz", + "integrity": "sha512-U5683Hi4uP5Wkg9IhHayqx8co9rgeZaAJcutLAgJiAN9ZnL128+WT0K4DKaBVuMbeeoORodVs9IJgM38lBxUxg==", "cpu": [ "x64" ], @@ -280,9 +280,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.178.tgz", - "integrity": "sha512-Z3U0fNatVK3vkki3e5sjlLJMjros+cT6uq//tdohB2kjNK1CugUuPQFQxd6GU1Lq1DokmdSEPL4OGdKiHckNdQ==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.179.tgz", + "integrity": "sha512-ehqWR9bV0dJONkoKleKg/LJIGDVevLGLPVIQpol+Bqrgyv05DE1hx68g6mBnfp9IEKo2BMZCba3aHKhkHlZvnQ==", "cpu": [ "arm64" ], @@ -306,9 +306,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.178.tgz", - "integrity": "sha512-K84Ybyr0Olsslg2I1Tzd7KIcSkZgFqqDHn7q1Dfqi7bXVxjMjJ4SaV+LnEAeHSM2es7H8A7ejoTEl89xMZde4Q==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.179.tgz", + "integrity": "sha512-Q+clijh01m+Gg/tSNjHQWfPFRvXSVMIQJZqu6v28vFduaucL7ZL+p6o6VOSdlgLjhqtIFFjOO37aL+VkvEu9MQ==", "cpu": [ "x64" ], @@ -319,9 +319,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.178.tgz", - "integrity": "sha512-uKH3vgv7cQV3d9BPU4lrUKrFgoU/flmy/1QI62j9JzgE6uWVQKWC+BI4V7iceJ36tYVneRhjjuux+l+Q8egTHA==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.179.tgz", + "integrity": "sha512-7wc4j3vDE/TiuiCEPV024U/TDNKXUJ89V0N9WteYJ57YrIUm0RA51WiKCrEmSxSqstQ7Slq26e0gWGHRf7ljDQ==", "cpu": [ "arm64" ], @@ -332,9 +332,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.178.tgz", - "integrity": "sha512-wSC5qG6UA1laWGylOU7axuC4NQxZJtibGQ79sYeOQCc05G7CfkUKSzszLOzsPP3eK63cKi/bX5PA6dd4nA5Wow==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.179.tgz", + "integrity": "sha512-gFad1AVUZOXbEyS9lf3Pr0LDXrLuO6limZoZoUZQmjhjy0LnudnTU8P/VbCY0AHmT46YMZ/ieisFzeq8X5jTcg==", "cpu": [ "x64" ], @@ -344,6 +344,32 @@ "win32" ] }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.179.tgz", + "integrity": "sha512-Io9X2xF5J3cHR20b0oflLe0sLHyT/KFMCA7sVJhwuSvcqeCHqXTxn6sG+4lArD9wNf+RtLVKgSvOt5Oz4j7mew==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.179.tgz", + "integrity": "sha512-Ck2pLG2GJ5lYULK0Rb6FvAUhkLSvIFgJ77MsbVhvBJHG3C31Fuk6FnkXEL614WSZZdI1ST0Y7s7wc9yLI2kmiQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@anthropic-ai/sdk": { "version": "0.104.2", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz", diff --git a/package.json b/package.json index d3f562a3..67e0b550 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "generate:schema": "ts-node --transpile-only --project tsconfig.lib.json scripts/generate-config-schema.ts" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.178", + "@anthropic-ai/claude-agent-sdk": "^0.3.179", "@anthropic-ai/sdk": "^0.104.2", "@aws-sdk/client-bedrock-runtime": "^3.1008.0", "@eggai/configurable-agent": "^0.2.1", diff --git a/src/ai/providers/anthropic.ts b/src/ai/providers/anthropic.ts index 6a6da948..4d697647 100644 --- a/src/ai/providers/anthropic.ts +++ b/src/ai/providers/anthropic.ts @@ -6,6 +6,8 @@ import { resolveSchemaName, schemaToJsonSchema, StructuredOutputError, + unwrapArrayRootResult, + wrapArrayRootSchema, } from '@/ai/shared/structured'; import { estimateTokens } from '@/ai/shared/token-utils'; import { ConfigService } from '@/config/config'; @@ -124,6 +126,12 @@ export class AnthropicProvider extends BaseAIProvider { const { use1HourCache, system } = this.buildSystemBlocks(messages, systemPrompt); const schemaName = resolveSchemaName(options.schema, options.schemaName); + // Anthropic's structured-output dialects require an object at the schema root. + // Wrap array-rooted schemas into { items: [...] } and unwrap the parsed payload. + const { schema: requestSchema, wrapped } = wrapArrayRootSchema(options.schema); + const unwrap = (value: unknown): z.infer => + (wrapped ? unwrapArrayRootResult(value) : value) as z.infer; + if (this.capabilities.structuredDialect === 'anthropic-output-config') { try { const response = await client.messages.parse({ @@ -132,7 +140,7 @@ export class AnthropicProvider extends BaseAIProvider { temperature, system, messages: this.toAnthropicMessages(messages), - output_config: { format: zodOutputFormat(options.schema) }, + output_config: { format: zodOutputFormat(requestSchema) }, }); const raw = this.extractText(response); if (response.parsed_output == null) { @@ -140,7 +148,7 @@ export class AnthropicProvider extends BaseAIProvider { } this.recordResponseUsage(response, messages, raw, use1HourCache); return { - content: response.parsed_output as z.infer, + content: unwrap(response.parsed_output), raw, usage: this.toTokenUsage(response.usage), model: response.model, @@ -152,7 +160,7 @@ export class AnthropicProvider extends BaseAIProvider { } // tool_use fallback for Claude < 4.5 - const toolSchema = schemaToJsonSchema(options.schema) as Record; + const toolSchema = schemaToJsonSchema(requestSchema) as Record; try { const response = await client.messages.create({ model, @@ -177,7 +185,7 @@ export class AnthropicProvider extends BaseAIProvider { if (!toolUseBlock) { throw new StructuredOutputError('Anthropic returned no tool_use block', raw); } - const parsed = options.schema.safeParse(toolUseBlock.input); + const parsed = requestSchema.safeParse(toolUseBlock.input); if (!parsed.success) { throw new StructuredOutputError( `Schema validation failed: ${parsed.error.message}`, @@ -187,7 +195,7 @@ export class AnthropicProvider extends BaseAIProvider { } this.recordResponseUsage(response, messages, raw, use1HourCache); return { - content: parsed.data, + content: unwrap(parsed.data), raw, usage: this.toTokenUsage(response.usage), model: response.model, diff --git a/src/ai/providers/openai-compatible-provider.ts b/src/ai/providers/openai-compatible-provider.ts index 58623550..ecf8e8ed 100644 --- a/src/ai/providers/openai-compatible-provider.ts +++ b/src/ai/providers/openai-compatible-provider.ts @@ -1,5 +1,4 @@ import type OpenAI from 'openai'; -import { zodResponseFormat } from 'openai/helpers/zod'; import type { ChatCompletionCreateParamsNonStreaming, ChatCompletionMessageParam, @@ -11,6 +10,8 @@ import { resolveSchemaName, schemaToJsonSchema, StructuredOutputError, + wrapArrayRootSchema, + unwrapArrayRootResult, } from '@/ai/shared/structured'; import { estimateTokens } from '@/ai/shared/token-utils'; import type { ResolvedStageConfig } from '@/shared/types'; @@ -112,23 +113,36 @@ export abstract class OpenAICompatibleProvider extends BaseAIProvider { const baseParams = this.buildBaseParams(options); const schemaName = resolveSchemaName(options.schema, options.schemaName); + // OpenAI's structured-output dialects require an object at the schema root, but + // several qualops review schemas are array-rooted. Wrap those into { items: [...] } + // for the request and unwrap the parsed payload, transparently to the caller. + const { schema: requestSchema, wrapped } = wrapArrayRootSchema(options.schema); + const unwrap = (value: unknown): z.infer => + (wrapped ? unwrapArrayRootResult(value) : value) as z.infer; + if (this.capabilities.structuredDialect === 'openai-json-schema-strict') { try { - const completion = await client.chat.completions.parse({ + // Use a non-strict `json_schema` response_format rather than the strict + // `zodResponseFormat` helper. Strict mode forbids `.optional()` fields unless + // they are also `.nullable()`, which several review schemas rely on; non-strict + // json_schema still constrains the model to the shape, and we validate the + // result with zod ourselves. + const jsonSchema = schemaToJsonSchema(requestSchema); + const response = await client.chat.completions.create({ ...baseParams, - response_format: zodResponseFormat(options.schema, schemaName), + response_format: { + type: 'json_schema', + json_schema: { name: schemaName, schema: jsonSchema, strict: false }, + }, }); - const message = completion.choices[0]?.message; - const raw = message?.content ?? ''; - if (!message?.parsed) { - throw new StructuredOutputError('OpenAI strict parser returned no parsed payload', raw); - } - this.recordResponseUsage(completion, baseParams, raw); + const raw = response.choices[0]?.message?.content ?? ''; + const parsed = parseAndValidate(raw, requestSchema); + this.recordResponseUsage(response, baseParams, raw); return { - content: message.parsed as z.infer, + content: unwrap(parsed), raw, - usage: this.toTokenUsage(completion.usage), - model: completion.model, + usage: this.toTokenUsage(response.usage), + model: response.model, }; } catch (error) { if (error instanceof StructuredOutputError) throw error; @@ -137,7 +151,7 @@ export abstract class OpenAICompatibleProvider extends BaseAIProvider { } // json_object dialect: ask for JSON, validate with zod ourselves. - const jsonSchema = schemaToJsonSchema(options.schema); + const jsonSchema = schemaToJsonSchema(requestSchema); const schemaInstruction = `Respond with a single JSON value that conforms to this JSON Schema. Do not wrap in markdown.\n\n` + `Schema (${schemaName}):\n${JSON.stringify(jsonSchema, null, 2)}`; @@ -154,10 +168,10 @@ export abstract class OpenAICompatibleProvider extends BaseAIProvider { response_format: { type: 'json_object' }, }); const raw = response.choices[0]?.message?.content ?? ''; - const parsed = parseAndValidate(raw, options.schema); + const parsed = parseAndValidate(raw, requestSchema); this.recordResponseUsage(response, baseParams, raw); return { - content: parsed, + content: unwrap(parsed), raw, usage: this.toTokenUsage(response.usage), model: response.model, diff --git a/src/ai/shared/structured/array-root.ts b/src/ai/shared/structured/array-root.ts new file mode 100644 index 00000000..6f1b35fa --- /dev/null +++ b/src/ai/shared/structured/array-root.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +/** + * The property name used to wrap an array-root schema into an object. + * OpenAI's structured-output dialects (json_schema strict and, in practice, + * json_object with schema guidance) require the root schema to be an object — + * a top-level array is rejected with "Root schema must have type: 'object'". + * Several qualops review schemas are array-rooted (review issues, validation + * results, root-cause classifications, dedup indices), so we transparently wrap + * them in `{ : [...] }` for the request and unwrap the parsed payload. + */ +export const ARRAY_ROOT_WRAP_KEY = 'items'; + +/** True when the schema's JSON-Schema root is an array (and therefore needs wrapping). */ +export function isArrayRootSchema(schema: z.ZodType): boolean { + // z.ZodArray is the only zod type that serializes to a root `type: "array"`. + return schema instanceof z.ZodArray; +} + +/** + * If `schema` is array-rooted, return an object schema `{ items: schema }` so it can + * be sent to a provider that requires an object root. Otherwise return the schema + * unchanged. `wrapped` tells the caller whether to unwrap the response. + */ +export function wrapArrayRootSchema( + schema: S, +): { schema: z.ZodType; wrapped: boolean } { + if (isArrayRootSchema(schema)) { + return { schema: z.object({ [ARRAY_ROOT_WRAP_KEY]: schema }), wrapped: true }; + } + return { schema, wrapped: false }; +} + +/** + * Unwrap a parsed payload produced against a wrapped array-root schema. Tolerant of + * shape drift: accepts the wrapper object `{ items: [...] }`, a bare array, or a + * single object (coerced to a one-element array) so a stray shape is not silently + * dropped to an empty result. + */ +export function unwrapArrayRootResult(value: unknown): unknown { + if (Array.isArray(value)) return value; + if (value && typeof value === 'object') { + const wrapper = value as Record; + const inner = wrapper[ARRAY_ROOT_WRAP_KEY]; + if (Array.isArray(inner)) return inner; + // A single object that looks like one item rather than the wrapper. + if (!(ARRAY_ROOT_WRAP_KEY in wrapper)) return [value]; + } + return value; +} diff --git a/src/ai/shared/structured/extract-json.ts b/src/ai/shared/structured/extract-json.ts index 0723e260..36bf6f0e 100644 --- a/src/ai/shared/structured/extract-json.ts +++ b/src/ai/shared/structured/extract-json.ts @@ -15,6 +15,11 @@ export function extractJsonText(response: string): ExtractedJson | null { const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); if (fenced?.[1]) return { text: fenced[1].trim(), source: 'fenced' }; + // Unclosed ```json fence (truncated response): extract everything after the opening marker. + // Restricted to explicit ```json — bare ``` could be any language block (Python, bash, etc.). + const unclosedFence = trimmed.match(/```json\s*([\s\S]+)/i); + if (unclosedFence?.[1]) return { text: unclosedFence[1].trim(), source: 'fenced' }; + if (looksLikeJson(trimmed)) return { text: trimmed, source: 'raw' }; const arrayMatch = trimmed.match(/(\[[\s\S]*\])/); diff --git a/src/ai/shared/structured/index.ts b/src/ai/shared/structured/index.ts index b01f0ba7..98893f08 100644 --- a/src/ai/shared/structured/index.ts +++ b/src/ai/shared/structured/index.ts @@ -1,4 +1,10 @@ export { extractJsonText, escapeUnescapedControlChars } from './extract-json'; +export { + ARRAY_ROOT_WRAP_KEY, + isArrayRootSchema, + wrapArrayRootSchema, + unwrapArrayRootResult, +} from './array-root'; export { schemaToJsonSchema } from './schema-to-json-schema'; export { resolveSchemaName } from './schema-name'; export { parseAndValidate, StructuredOutputError } from './validate'; diff --git a/src/ai/shared/structured/schema-to-json-schema.ts b/src/ai/shared/structured/schema-to-json-schema.ts index 1771186e..44b7babb 100644 --- a/src/ai/shared/structured/schema-to-json-schema.ts +++ b/src/ai/shared/structured/schema-to-json-schema.ts @@ -9,14 +9,52 @@ export interface SchemaToJsonOptions { * Throws on incompatibility instead of silently mutating. */ enforceStrictDialect?: boolean; + /** + * When true, strip numeric and string constraints (minimum, maximum, multipleOf, + * minLength, maxLength) that are not supported by Anthropic's structured output + * constrained decoding (output_config.format / --json-schema). + */ + stripUnsupportedConstraints?: boolean; +} + +// Constraint keys not supported by Anthropic's structured output constrained decoding. +const UNSUPPORTED_CONSTRAINT_KEYS = new Set([ + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', + 'minLength', + 'maxLength', + 'pattern', + 'minItems', + 'maxItems', +]); + +function stripConstraints(node: unknown): unknown { + if (node === null || typeof node !== 'object') return node; + if (Array.isArray(node)) return node.map(stripConstraints); + const obj = node as Record; + return Object.fromEntries( + Object.entries(obj) + .filter(([k]) => !UNSUPPORTED_CONSTRAINT_KEYS.has(k)) + .map(([k, v]) => [k, stripConstraints(v)]), + ); } export function schemaToJsonSchema( schema: S, options: SchemaToJsonOptions = {}, ): Record { - const { target = 'draft-2020-12', enforceStrictDialect = false } = options; - const json = z.toJSONSchema(schema, { target }) as Record; + const { + target = 'draft-2020-12', + enforceStrictDialect = false, + stripUnsupportedConstraints = false, + } = options; + let json = z.toJSONSchema(schema, { target }) as Record; + if (stripUnsupportedConstraints) { + json = stripConstraints(json) as Record; + } if (enforceStrictDialect) { validateStrictDialect(json, '$'); } diff --git a/src/stages/review/agentic/adapters/agent-adapter.ts b/src/stages/review/agentic/adapters/agent-adapter.ts index bf43e019..d65c5ea4 100644 --- a/src/stages/review/agentic/adapters/agent-adapter.ts +++ b/src/stages/review/agentic/adapters/agent-adapter.ts @@ -26,10 +26,13 @@ export type AgentErrorSubtype = | 'error_rate_limit_tokens' | 'error_max_tokens' | 'error_content_filter' + | 'error_parse_failed' | 'error_unexpected'; export interface AgentAdapterResult { output: string; + /** Pre-parsed issues array from SDK structured output. Bypasses text parsing when set. */ + structuredOutput?: unknown; inputTokens?: number; outputTokens?: number; /** Set when the agent run did not complete successfully. */ diff --git a/src/stages/review/agentic/adapters/anthropic-adapter.ts b/src/stages/review/agentic/adapters/anthropic-adapter.ts index e2b51a83..6eb598ff 100644 --- a/src/stages/review/agentic/adapters/anthropic-adapter.ts +++ b/src/stages/review/agentic/adapters/anthropic-adapter.ts @@ -1,5 +1,8 @@ import { query, tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { ReviewIssuesSchema } from '../../../../ai/shared/schemas/review-issue'; +import { schemaToJsonSchema } from '../../../../ai/shared/structured'; import { createToolSet, type ToolSet } from '../tools'; import type { AgentAdapter, @@ -9,6 +12,16 @@ import type { } from './agent-adapter'; import { logger } from '../../../../shared/utils/logger'; +// SDK requires a root object schema — wrap the array in { issues: [...] }. +// stripUnsupportedConstraints removes minimum/maximum/minLength etc. which are +// not supported by Anthropic's structured output constrained decoding. +// The $schema field (draft-2020-12 URI) must be omitted — the CLI rejects +// structured output when it is present and falls back to plain text. +const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); +const { $schema: _dropped, ...REVIEW_ISSUES_JSON_SCHEMA } = schemaToJsonSchema(ReviewOutputSchema, { + stripUnsupportedConstraints: true, +}); + type QueryOptions = Parameters[0]['options']; function toMcpServer(toolSet: ToolSet): ReturnType { @@ -54,6 +67,7 @@ function buildQueryOptions( ...(model && { model }), cwd, permissionMode: 'bypassPermissions', + outputFormat: { type: 'json_schema' as const, schema: REVIEW_ISSUES_JSON_SCHEMA }, }; } @@ -133,6 +147,7 @@ function handleResultMessage( message: SDKMessage, state: { output: string; + structuredOutput?: unknown; inputTokens?: number; outputTokens?: number; errorSubtype?: AgentErrorSubtype; @@ -141,15 +156,30 @@ function handleResultMessage( const msg = message as { subtype: string; result?: string; + structured_output?: unknown; usage?: { input_tokens?: number; output_tokens?: number }; }; - if (msg.subtype === 'success' && msg.result) { - logger.info( - `[Agentic/Anthropic] Success result (first 500 chars): ${msg.result.substring(0, 500)}`, - ); - state.output = msg.result; + if (msg.subtype === 'success') { state.inputTokens = msg.usage?.input_tokens; state.outputTokens = msg.usage?.output_tokens; + const issues = (msg.structured_output as { issues?: unknown } | undefined)?.issues; + if (Array.isArray(issues)) { + logger.info( + `[Agentic/Anthropic] Structured output (first 500 chars): ${JSON.stringify(issues).substring(0, 500)}`, + ); + state.structuredOutput = issues; + } else if (msg.result) { + if (msg.structured_output !== undefined) { + logger.warn( + `[Agentic/Anthropic] Unexpected structured_output shape — falling back to text result. Got: ${JSON.stringify(msg.structured_output).substring(0, 200)}`, + ); + } else { + logger.info( + `[Agentic/Anthropic] Success result (first 500 chars): ${msg.result.substring(0, 500)}`, + ); + } + state.output = msg.result; + } } else if (msg.subtype !== 'success') { const mapped = ANTHROPIC_ERROR_SUBTYPE_MAP[msg.subtype] ?? 'error_unexpected'; if (mapped === 'error_unexpected') { @@ -173,6 +203,7 @@ export class AnthropicAdapter implements AgentAdapter { const state = { output: '', + structuredOutput: undefined as unknown, inputTokens: undefined as number | undefined, outputTokens: undefined as number | undefined, errorSubtype: undefined as AgentErrorSubtype | undefined, diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index 217e8038..6d5301ca 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -8,13 +8,37 @@ import type { AgentAdapterResult, AgentErrorSubtype, } from './agent-adapter'; +import { ReviewIssuesSchema } from '../../../../ai/shared/schemas/review-issue'; +import { schemaToJsonSchema } from '../../../../ai/shared/structured'; import { envConfig } from '../../../../config/env'; import { logger } from '../../../../shared/utils/logger'; import { createToolSet } from '../tools'; +// generateObject (Vercel AI SDK) requires a root object schema — wrap the array. +// stripUnsupportedConstraints removes minimum/maximum/minLength etc. which are +// not supported by constrained-decoding structured output implementations. +const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); +const { $schema: _dropped, ...REVIEW_ISSUES_JSON_SCHEMA } = schemaToJsonSchema(ReviewOutputSchema, { + stripUnsupportedConstraints: true, +}); + +/** + * Extract the issues array from a structured-output payload. Tolerant of shape drift: + * accepts the wrapper `{ issues: [...] }`, a bare array, or a single issue object + * (coerced to a one-element array), so an unexpected shape is not silently dropped. + */ +function unwrapStructuredIssues(structured: unknown): unknown { + if (Array.isArray(structured)) return structured; + if (structured && typeof structured === 'object') { + const wrapper = structured as { issues?: unknown }; + if (Array.isArray(wrapper.issues)) return wrapper.issues; + if (!('issues' in wrapper)) return [structured]; + } + return structured; +} + function toJsonSchema(schema: z.ZodObject): Record { - // z.toJSONSchema emits Draft 2020-12 with a $schema key that confuses some providers. - // Strip it and emit a plain draft-07-compatible object instead. + // Used for tool input schemas. Strip $schema (draft-2020-12 URI) which confuses some providers. const { $schema: _dropped, ...rest } = z.toJSONSchema(schema) as Record; return rest; } @@ -49,7 +73,7 @@ function buildAgentConfig(params: AgentAdapterParams, toolNames: string[]) { }, agent: { maxSteps: params.maxTurns }, mcpTools: [], - output: { structured: false }, + output: { structured: true as const, schema: REVIEW_ISSUES_JSON_SCHEMA }, safety: { compaction: { triggerTokens: 100_000, keepRecentMessages: 6 }, toolOutput: { triggerTokens: 4_000, headChars: 500, tailChars: 500 }, @@ -60,7 +84,16 @@ function buildAgentConfig(params: AgentAdapterParams, toolNames: string[]) { function buildModel(params: AgentAdapterParams) { const baseURL = params.baseUrl ?? envConfig.get('openaiBaseUrl') ?? 'https://api.openai.com/v1'; const apiKey = envConfig.get('openaiApiKey') ?? ''; - return createOpenAICompatible({ name: 'openai-compatible', baseURL, apiKey })(params.model); + // supportsStructuredOutputs makes the AI SDK send a `json_schema` response_format + // (constrained decoding) instead of loose `json_object`, so the model is forced to + // return the wrapped { issues: [...] } shape. Without it the model free-forms its + // output and the structured result silently parses to zero issues. + return createOpenAICompatible({ + name: 'openai-compatible', + baseURL, + apiKey, + supportsStructuredOutputs: true, + })(params.model); } export class ConfigurableAgentAdapter implements AgentAdapter { @@ -109,6 +142,7 @@ export class ConfigurableAgentAdapter implements AgentAdapter { try { let output = ''; + let structuredOutput: unknown = undefined; let inputTokens: number | undefined; let outputTokens: number | undefined; let errorSubtype: AgentErrorSubtype | undefined; @@ -124,14 +158,28 @@ export class ConfigurableAgentAdapter implements AgentAdapter { logger.info(`[Agentic/ConfigurableAgent] Tool call: ${event.name}`); params.onToolCall?.(turnIndex, event.name, event.args); break; - case 'final': - output = event.content; + case 'final': { + const finalEvent = event as typeof event & { structured?: unknown }; + if (finalEvent.structured !== undefined) { + // The schema is wrapped as { issues: [...] }, but be tolerant of shape + // drift (bare array, or a single object) so a stray response is not + // silently dropped to zero issues. + const issues = unwrapStructuredIssues(finalEvent.structured); + const preview = JSON.stringify(issues).substring(0, 500); + logger.info( + `[Agentic/ConfigurableAgent] Structured output (first 500 chars): ${preview}`, + ); + structuredOutput = issues; + } else { + output = event.content; + } inputTokens = event.usage.inputTokens; outputTokens = event.usage.outputTokens; logger.info( `[Agentic/ConfigurableAgent] Finished. steps=${event.steps}, stopReason=${event.stopReason}`, ); break; + } case 'error': { logger.warn( `[Agentic/ConfigurableAgent] Error: code=${event.code} — ${event.message}`, @@ -155,7 +203,7 @@ export class ConfigurableAgentAdapter implements AgentAdapter { { tools: tools as never, model }, ); - return { output, inputTokens, outputTokens, errorSubtype }; + return { output, structuredOutput, inputTokens, outputTokens, errorSubtype }; } finally { await qualopsTools.dispose(); } diff --git a/src/stages/review/agentic/adapters/openai-adapter.ts b/src/stages/review/agentic/adapters/openai-adapter.ts index 195a98e8..1001d9df 100644 --- a/src/stages/review/agentic/adapters/openai-adapter.ts +++ b/src/stages/review/agentic/adapters/openai-adapter.ts @@ -1,10 +1,14 @@ import { Agent, getGlobalTraceProvider, run, setDefaultOpenAIClient, tool } from '@openai/agents'; +import { z } from 'zod'; import type { AgentAdapter, AgentAdapterParams, AgentAdapterResult } from './agent-adapter'; +import { ReviewIssuesSchema } from '../../../../ai/shared/schemas/review-issue'; import { envConfig } from '../../../../config/env'; import { logger } from '../../../../shared/utils/logger'; import { createToolSet, type ToolSet } from '../tools'; +const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); + export class OpenAIAdapter implements AgentAdapter { async run(params: AgentAdapterParams): Promise { const { @@ -43,27 +47,29 @@ export class OpenAIAdapter implements AgentAdapter { instructions: systemPrompt, tools, handoffs, + outputType: ReviewOutputSchema, }); logger.info(`[Agentic/OpenAI] Starting run with model=${model}, maxTurns=${maxTurns}`); const result = await run(orchestrator, userPrompt, { maxTurns }); - const output = - typeof result.finalOutput === 'string' - ? result.finalOutput - : result.finalOutput != null - ? JSON.stringify(result.finalOutput) - : ''; - const usage = result.state.usage; + const structured = result.finalOutput as z.infer | null; logger.info( `[Agentic/OpenAI] Run complete. inputTokens=${usage.inputTokens}, outputTokens=${usage.outputTokens}`, ); + if (!structured?.issues) { + throw new Error( + `[Agentic/OpenAI] Run completed but finalOutput is missing — agent likely hit max turns (${maxTurns})`, + ); + } + logger.info(`[Agentic/OpenAI] Structured output (${structured.issues.length} issues)`); return { - output, + output: '', + structuredOutput: structured.issues, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, }; diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index 50c169f6..d53d4a1f 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -4,12 +4,13 @@ import { createAgentAdapter } from './adapters'; import type { AgentErrorSubtype } from './adapters/agent-adapter'; import { AgentLoader } from './loaders/agent-loader'; import { buildUserPrompt } from './prompt-builder'; -import { parseIssuesFromResult } from './result-parser'; +import { normalizeIssue, parseIssuesFromResult } from './result-parser'; import { createSubagentDefinitions, type AgentDefinition, type ResolvedAgentDefinition, } from './subagents/definitions'; +import { extractJsonText } from '../../../ai/shared/structured'; import { ConfigService } from '../../../config/config'; import { getTracer, @@ -148,12 +149,29 @@ export class AgenticExecutor { ); } - if (result.output) { + if (result.structuredOutput !== undefined) { + if (!Array.isArray(result.structuredOutput)) { + throw new Error( + `[Agentic] Job "${this.job.name}" structured output is not an array. Got: ${JSON.stringify(result.structuredOutput).substring(0, 200)}`, + ); + } + const parsed = (result.structuredOutput as Record[]) + .filter((i) => ((i?.confidence as number) ?? 0) >= 7) + .map((i, idx) => normalizeIssue(i, idx, files, this.job.name, this.cwd)); + issues.push(...parsed); + logger.info(`[Agentic] Parsed ${parsed.length} issues from structured output`); + } else if (result.output) { logger.info(`[Agentic] Result (first 500 chars): ${result.output.substring(0, 500)}`); const parsed = parseIssuesFromResult(result.output, files, this.job.name, this.cwd); - if (parsed.length === 0 && result.output.trim().length > 0) { + // Only throw when the output contains no JSON-like structure at all. + // parsed.length === 0 is valid when the model returns [] (no issues found). + const hasJsonContent = extractJsonText(result.output) !== null; + if (parsed.length === 0 && result.output.trim().length > 0 && !hasJsonContent) { logger.warn( - `[Agentic] Job "${this.job.name}" returned output but no parseable issues — response may be truncated (${result.output.length} chars).`, + `[Agentic] No parseable issues from text output. Raw output preview:\n${result.output.substring(0, 2000)}`, + ); + throw new Error( + `[Agentic] Job "${this.job.name}" returned non-empty output but no parseable JSON issues (${result.output.length} chars).`, ); } issues.push(...parsed); diff --git a/src/stages/review/agentic/result-parser.ts b/src/stages/review/agentic/result-parser.ts index 576258d2..98114ec7 100644 --- a/src/stages/review/agentic/result-parser.ts +++ b/src/stages/review/agentic/result-parser.ts @@ -53,7 +53,9 @@ export function parseIssuesFromResult( parsed = JSON.parse(extracted.text); } catch { try { - parsed = JSON.parse(escapeUnescapedControlChars(extracted.text)); + parsed = JSON.parse( + escapeUnescapedControlChars(extracted.text.replace(/,(\s*[}\]])/g, '$1')), + ); } catch (error) { logger.warn(`[Agentic] Failed to parse JSON: ${(error as Error).message}`); logger.warn(`[Agentic] JSON preview: ${extracted.text.slice(0, 300)}...`); @@ -156,7 +158,9 @@ export function recoverPartialJsonArray(text: string): unknown[] { depth--; if (depth === 0 && start !== -1) { try { - results.push(JSON.parse(text.slice(start, i + 1))); + const chunk = text.slice(start, i + 1); + const fixed = escapeUnescapedControlChars(chunk.replace(/,(\s*[}\]])/g, '$1')); + results.push(JSON.parse(fixed)); } catch { // skip malformed object } diff --git a/src/stages/review/agentic/tools/handlers.ts b/src/stages/review/agentic/tools/handlers.ts index a4d8a9b8..c2b46ccb 100644 --- a/src/stages/review/agentic/tools/handlers.ts +++ b/src/stages/review/agentic/tools/handlers.ts @@ -129,6 +129,7 @@ export function listChangedFiles( if (!isSafeGitRef(base)) return `Error: invalid git ref: ${base}`; const headRef = head || 'HEAD'; if (!isSafeGitRef(headRef)) return `Error: invalid git ref: ${headRef}`; + if (filter && !/^[ACDMRTUXB*]+$/i.test(filter)) return `Error: invalid diff filter: ${filter}`; const args = ['diff', '--name-status']; if (filter) args.push(`--diff-filter=${filter}`); args.push(`${base}...${headRef}`); diff --git a/tests/unit/ai/shared/structured/array-root.spec.ts b/tests/unit/ai/shared/structured/array-root.spec.ts new file mode 100644 index 00000000..51a211cc --- /dev/null +++ b/tests/unit/ai/shared/structured/array-root.spec.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; + +import { + ARRAY_ROOT_WRAP_KEY, + isArrayRootSchema, + unwrapArrayRootResult, + wrapArrayRootSchema, +} from '@/ai/shared/structured'; + +describe('isArrayRootSchema', () => { + it('returns true for z.array()', () => { + expect(isArrayRootSchema(z.array(z.string()))).toBe(true); + }); + + it('returns false for z.object()', () => { + expect(isArrayRootSchema(z.object({ a: z.string() }))).toBe(false); + }); + + it('returns false for z.string()', () => { + expect(isArrayRootSchema(z.string())).toBe(false); + }); +}); + +describe('wrapArrayRootSchema', () => { + it('wraps a z.array() schema into { [ARRAY_ROOT_WRAP_KEY]: schema }', () => { + const inner = z.array(z.string()); + const { schema, wrapped } = wrapArrayRootSchema(inner); + expect(wrapped).toBe(true); + expect(schema).toBeInstanceOf(z.ZodObject); + const shape = (schema as z.ZodObject).shape; + expect(shape[ARRAY_ROOT_WRAP_KEY]).toBe(inner); + }); + + it('returns the schema unchanged when it is already an object', () => { + const obj = z.object({ x: z.number() }); + const { schema, wrapped } = wrapArrayRootSchema(obj); + expect(wrapped).toBe(false); + expect(schema).toBe(obj); + }); + + it('wrapped schema validates array payload correctly', () => { + const { schema } = wrapArrayRootSchema(z.array(z.number())); + const result = schema.safeParse({ [ARRAY_ROOT_WRAP_KEY]: [1, 2, 3] }); + expect(result.success).toBe(true); + }); +}); + +describe('unwrapArrayRootResult', () => { + it('returns a bare array as-is', () => { + expect(unwrapArrayRootResult([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('unwraps { items: [...] } to the inner array', () => { + expect(unwrapArrayRootResult({ [ARRAY_ROOT_WRAP_KEY]: [1, 2] })).toEqual([1, 2]); + }); + + it('coerces a single object with no wrap key to a one-element array', () => { + const obj = { foo: 'bar' }; + expect(unwrapArrayRootResult(obj)).toEqual([obj]); + }); + + it('returns value unchanged for null/primitive', () => { + expect(unwrapArrayRootResult(null)).toBeNull(); + expect(unwrapArrayRootResult(42)).toBe(42); + }); + + it('returns { items: null } inner value (null, not coerced)', () => { + // inner is null, not an array → falls through to return value as-is + const val = { [ARRAY_ROOT_WRAP_KEY]: null }; + expect(unwrapArrayRootResult(val)).toEqual(val); + }); +}); diff --git a/tests/unit/ai/shared/structured/extract-json.spec.ts b/tests/unit/ai/shared/structured/extract-json.spec.ts index 6fd6ec86..576aff36 100644 --- a/tests/unit/ai/shared/structured/extract-json.spec.ts +++ b/tests/unit/ai/shared/structured/extract-json.spec.ts @@ -40,6 +40,18 @@ describe('extractJsonText', () => { it('returns null when no JSON-like content found', () => { expect(extractJsonText('just plain text')).toBeNull(); }); + + it('extracts JSON from an unclosed fenced block (truncated response)', () => { + const out = extractJsonText('Here is the result:\n```json\n[{"a":1},{"b":2}'); + expect(out?.source).toBe('fenced'); + expect(out?.text).toBe('[{"a":1},{"b":2}'); + }); + + it('does not extract from unclosed bare ``` block (could be any language)', () => { + // No extractable JSON — bare fence with incomplete object (no closing `}`) returns null + const out = extractJsonText('Result:\n```\n{"key":"value"'); + expect(out).toBeNull(); + }); }); describe('escapeUnescapedControlChars', () => { diff --git a/tests/unit/ai/shared/structured/schema-to-json-schema.spec.ts b/tests/unit/ai/shared/structured/schema-to-json-schema.spec.ts index b8645aa2..886bd072 100644 --- a/tests/unit/ai/shared/structured/schema-to-json-schema.spec.ts +++ b/tests/unit/ai/shared/structured/schema-to-json-schema.spec.ts @@ -34,6 +34,54 @@ describe('schemaToJsonSchema', () => { expect(out.description).toBe('a list'); }); + describe('stripUnsupportedConstraints', () => { + it('removes minimum, maximum, multipleOf from number fields', () => { + const out = schemaToJsonSchema(z.object({ n: z.number().int().min(1).max(10) }), { + stripUnsupportedConstraints: true, + }) as Record>>; + const nProp = out.properties.n; + expect(nProp.minimum).toBeUndefined(); + expect(nProp.maximum).toBeUndefined(); + expect(nProp.multipleOf).toBeUndefined(); + expect(nProp.type).toBe('integer'); + }); + + it('removes minLength, maxLength from string fields', () => { + const out = schemaToJsonSchema(z.object({ s: z.string().min(1).max(100) }), { + stripUnsupportedConstraints: true, + }) as Record>>; + const sProp = out.properties.s; + expect(sProp.minLength).toBeUndefined(); + expect(sProp.maxLength).toBeUndefined(); + expect(sProp.type).toBe('string'); + }); + + it('preserves description and required fields', () => { + const out = schemaToJsonSchema( + z.object({ s: z.string().min(1).describe('non-empty string') }), + { stripUnsupportedConstraints: true }, + ) as Record>>; + expect(out.properties.s.description).toBe('non-empty string'); + expect((out.required as string[]).includes('s')).toBe(true); + }); + + it('strips constraints at all nesting levels', () => { + const out = schemaToJsonSchema( + z.object({ items: z.array(z.object({ n: z.number().min(0) })) }), + { stripUnsupportedConstraints: true }, + ) as Record>>>>; + expect(out.properties.items.items.properties.n.minimum).toBeUndefined(); + }); + + it('does not strip constraints when option is false (default)', () => { + const out = schemaToJsonSchema(z.object({ n: z.number().min(1) })) as Record< + string, + Record> + >; + expect(out.properties.n.minimum).toBe(1); + }); + }); + describe('strict-dialect validation', () => { it('accepts a schema with all properties required and additionalProperties:false', () => { expect(() => diff --git a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts index 55d6def0..70ea4f31 100644 --- a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts @@ -39,7 +39,7 @@ describe('AnthropicAdapter', () => { mockCreateToolSet.mockResolvedValue({ tools: [], dispose: jest.fn() }); }); - it('returns output from a successful result message', async () => { + it('falls back to text output when structured_output is absent from result', async () => { mockQuery.mockReturnValue( (async function* () { yield { type: 'result', subtype: 'success', result: '["issue1"]' }; @@ -48,6 +48,7 @@ describe('AnthropicAdapter', () => { const adapter = new AnthropicAdapter(); const result = await adapter.run(makeParams()); expect(result.output).toBe('["issue1"]'); + expect(result.structuredOutput).toBeUndefined(); }); it('extracts token counts from usage in result message', async () => { @@ -67,25 +68,13 @@ describe('AnthropicAdapter', () => { expect(result.outputTokens).toBe(50); }); - it('returns empty output and errorSubtype when result subtype is not success', async () => { - mockQuery.mockReturnValue( - (async function* () { - yield { type: 'result', subtype: 'error_max_turns' }; - })(), - ); - const adapter = new AnthropicAdapter(); - const result = await adapter.run(makeParams()); - expect(result.output).toBe(''); - expect(result.errorSubtype).toBe('error_max_turns'); - }); - it.each([ ['error_max_turns', 'error_max_turns'], ['error_during_execution', 'error_provider_unavailable'], ['error_max_budget_usd', 'error_rate_limit_tokens'], ['error_max_structured_output_retries', 'error_content_filter'], ] as const)( - 'maps SDK subtype "%s" to qualops subtype "%s"', + 'maps SDK subtype "%s" to qualops subtype "%s" and returns empty output', async (sdkSubtype, expectedSubtype) => { mockQuery.mockReturnValue( (async function* () { @@ -95,6 +84,7 @@ describe('AnthropicAdapter', () => { const adapter = new AnthropicAdapter(); const result = await adapter.run(makeParams()); expect(result.errorSubtype).toBe(expectedSubtype); + expect(result.output).toBe(''); }, ); @@ -140,6 +130,49 @@ describe('AnthropicAdapter', () => { expect(callOptions.maxBudgetUsd).toBe(2.5); }); + it('returns structuredOutput when result contains structured_output wrapper', async () => { + const issues = [{ description: 'sql injection', confidence: 9 }]; + mockQuery.mockReturnValue( + (async function* () { + // SDK returns the root object wrapper { issues: [...] } matching our schema + yield { type: 'result', subtype: 'success', structured_output: { issues } }; + })(), + ); + const adapter = new AnthropicAdapter(); + const result = await adapter.run(makeParams()); + expect(result.structuredOutput).toEqual(issues); + expect(result.output).toBe(''); + }); + + it('falls back to result text when structured_output has unexpected shape', async () => { + mockQuery.mockReturnValue( + (async function* () { + yield { + type: 'result', + subtype: 'success', + structured_output: { unexpected: 'shape' }, + result: '[{"description":"fallback issue"}]', + }; + })(), + ); + const adapter = new AnthropicAdapter(); + const result = await adapter.run(makeParams()); + expect(result.structuredOutput).toBeUndefined(); + expect(result.output).toBe('[{"description":"fallback issue"}]'); + }); + + it('returns empty output when structured_output is malformed and no result text', async () => { + mockQuery.mockReturnValue( + (async function* () { + yield { type: 'result', subtype: 'success', structured_output: { unexpected: 'shape' } }; + })(), + ); + const adapter = new AnthropicAdapter(); + const result = await adapter.run(makeParams()); + expect(result.structuredOutput).toBeUndefined(); + expect(result.output).toBe(''); + }); + it('rethrows when query async generator throws', async () => { mockQuery.mockReturnValue( (async function* () { @@ -178,7 +211,7 @@ describe('AnthropicAdapter', () => { expect((callOptions.tools as string[]).includes('Bash')).toBe(false); }); - it('logs mcp bash tool_use and tool_result output', async () => { + it('invokes onToolCall for mcp bash tool_use blocks', async () => { mockQuery.mockReturnValue( (async function* () { yield { @@ -221,7 +254,7 @@ describe('AnthropicAdapter', () => { }); }); - it('logs assistant text blocks', async () => { + it('processes assistant text blocks without affecting output', async () => { mockQuery.mockReturnValue( (async function* () { yield { diff --git a/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts index e0d425ba..587166de 100644 --- a/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts @@ -48,9 +48,9 @@ function makeParams(overrides: Partial = {}): AgentAdapterPa }; } -function makeRunResult(finalOutput: string, inputTokens = 0, outputTokens = 0) { +function makeRunResult(inputTokens = 0, outputTokens = 0) { return { - finalOutput, + finalOutput: { issues: [] }, state: { usage: { inputTokens, outputTokens } }, }; } @@ -71,51 +71,46 @@ describe('OpenAIAdapter — run()', () => { MockAgentCtor.mockImplementation(() => ({}) as InstanceType); }); - it('returns output from run() finalOutput', async () => { - mockRunFn.mockResolvedValue(makeRunResult('["issue1"]') as any); + it('returns structuredOutput from run() finalOutput.issues', async () => { + const issues = [{ description: 'issue1', confidence: 9 }]; + mockRunFn.mockResolvedValue({ + finalOutput: { issues }, + state: { usage: { inputTokens: 0, outputTokens: 0 } }, + } as any); const result = await new OpenAIAdapter().run(makeParams()); - expect(result.output).toBe('["issue1"]'); + expect(result.structuredOutput).toEqual(issues); + expect(result.output).toBe(''); }); it('extracts token counts from state.usage', async () => { - mockRunFn.mockResolvedValue(makeRunResult('[]', 120, 60) as any); + mockRunFn.mockResolvedValue(makeRunResult(120, 60) as any); const result = await new OpenAIAdapter().run(makeParams()); expect(result.inputTokens).toBe(120); expect(result.outputTokens).toBe(60); }); - it('returns empty string when finalOutput is null', async () => { + it('throws when finalOutput is null', async () => { mockRunFn.mockResolvedValue({ finalOutput: null, state: { usage: { inputTokens: 0, outputTokens: 0 } }, } as any); - const result = await new OpenAIAdapter().run(makeParams()); - expect(result.output).toBe(''); - }); - - it('serialises non-string finalOutput to JSON', async () => { - mockRunFn.mockResolvedValue({ - finalOutput: { key: 'val' }, - state: { usage: { inputTokens: 0, outputTokens: 0 } }, - } as any); - const result = await new OpenAIAdapter().run(makeParams()); - expect(result.output).toBe('{"key":"val"}'); + await expect(new OpenAIAdapter().run(makeParams())).rejects.toThrow('finalOutput is missing'); }); it('passes maxTurns to run()', async () => { - mockRunFn.mockResolvedValue(makeRunResult('[]') as any); + mockRunFn.mockResolvedValue(makeRunResult() as any); await new OpenAIAdapter().run(makeParams({ maxTurns: 42 })); expect((mockRunFn.mock.calls[0][2] as { maxTurns: number }).maxTurns).toBe(42); }); it('creates one orchestrator when agents is empty', async () => { - mockRunFn.mockResolvedValue(makeRunResult('[]') as any); + mockRunFn.mockResolvedValue(makeRunResult() as any); await new OpenAIAdapter().run(makeParams()); expect(MockAgentCtor).toHaveBeenCalledTimes(1); }); it('creates orchestrator + handoff agent for each entry in agents', async () => { - mockRunFn.mockResolvedValue(makeRunResult('[]') as any); + mockRunFn.mockResolvedValue(makeRunResult() as any); await new OpenAIAdapter().run( makeParams({ agents: { @@ -140,7 +135,7 @@ describe('OpenAIAdapter — tools', () => { beforeEach(() => { jest.clearAllMocks(); MockAgentCtor.mockImplementation((opts: any) => opts as InstanceType); - mockRunFn.mockResolvedValue(makeRunResult('[]') as any); + mockRunFn.mockResolvedValue(makeRunResult() as any); }); async function getToolExecute(name: string) { @@ -222,11 +217,15 @@ describe('OpenAIAdapter — tools', () => { expect(onToolCall).toHaveBeenCalledWith(1, 'read_file', { filePath: 'src/foo.ts' }); }); - it('continues without bash tool when startBashSession throws', async () => { + it('continues and returns structuredOutput when startBashSession throws', async () => { + const issues = [{ description: 'issue', confidence: 8 }]; mockStartBashSession.mockRejectedValueOnce(new Error('spawn failed')); - mockRunFn.mockResolvedValue(makeRunResult('[]') as never); + mockRunFn.mockResolvedValue({ + finalOutput: { issues }, + state: { usage: { inputTokens: 0, outputTokens: 0 } }, + } as never); const result = await new OpenAIAdapter().run(makeParams()); - expect(result.output).toBe('[]'); + expect(result.structuredOutput).toEqual(issues); }); it('calls dispose in finally even when run throws', async () => { diff --git a/tests/unit/stages/review/agentic/agentic-executor.spec.ts b/tests/unit/stages/review/agentic/agentic-executor.spec.ts index 3a316651..81a44ecc 100644 --- a/tests/unit/stages/review/agentic/agentic-executor.spec.ts +++ b/tests/unit/stages/review/agentic/agentic-executor.spec.ts @@ -102,6 +102,76 @@ describe('AgenticExecutor — execute()', () => { expect(result).toEqual([]); }); + it('parses issues from structuredOutput when adapter returns it', async () => { + const issue = { + type: 'security', + severity: 'high', + description: 'SQL injection', + location: 'src/db.ts:10', + confidence: 9, + }; + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async () => ({ output: '', structuredOutput: [issue] })), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + const result = await executor.execute([{ path: 'src/db.ts', content: 'query(input)' }]); + expect(result).toHaveLength(1); + expect(result[0].description).toBe('SQL injection'); + }); + + it('passes onToolCall to adapter and tracks turn index', async () => { + let capturedOnToolCall: AgentAdapterParams['onToolCall']; + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async (params: AgentAdapterParams) => { + capturedOnToolCall = params.onToolCall; + params.onToolCall?.(3, 'read_file', { filePath: 'src/foo.ts' }); + return { output: '[]' }; + }), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + await executor.execute([{ path: 'src/foo.ts', content: 'x' }]); + expect(capturedOnToolCall).toBeDefined(); + }); + + it('throws on hard failure error subtypes', async () => { + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async () => ({ output: '', errorSubtype: 'error_rate_limit_tokens' as const })), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + await expect(executor.execute([{ path: 'src/foo.ts', content: 'x' }])).rejects.toThrow( + 'error_rate_limit_tokens', + ); + }); + + it('returns empty and does not throw on soft error subtypes', async () => { + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async () => ({ output: '', errorSubtype: 'error_max_turns' as const })), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + const result = await executor.execute([{ path: 'src/foo.ts', content: 'x' }]); + expect(result).toEqual([]); + }); + + it('throws when structuredOutput is not an array', async () => { + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async () => ({ output: '', structuredOutput: { unexpected: 'object' } })), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + await expect(executor.execute([{ path: 'src/foo.ts', content: 'x' }])).rejects.toThrow( + 'structured output is not an array', + ); + }); + + it('throws when non-empty text output contains no JSON', async () => { + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async () => ({ output: 'No issues found in this code.' })), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + await expect(executor.execute([{ path: 'src/foo.ts', content: 'x' }])).rejects.toThrow( + 'no parseable JSON issues', + ); + }); + it('rethrows when adapter throws', async () => { mockCreateAgentAdapter.mockReturnValue({ run: jest.fn(async () => { @@ -114,11 +184,13 @@ describe('AgenticExecutor — execute()', () => { ); }); - it('passes resolved provider to createAgentAdapter', async () => { + it('passes the provider from ConfigService to createAgentAdapter', async () => { setupMockAdapter(); const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); await executor.execute([{ path: 'src/foo.ts', content: 'x' }]); - expect(mockCreateAgentAdapter).toHaveBeenCalledWith(expect.any(String)); + // ConfigService reads from .qualopsrc.json; provider must be a known AIProviderName + const calledWith = mockCreateAgentAdapter.mock.calls[0][0]; + expect(['anthropic', 'openai', 'openai-compatible', 'github', 'bedrock']).toContain(calledWith); }); }); diff --git a/tests/unit/stages/review/agentic/result-parser.spec.ts b/tests/unit/stages/review/agentic/result-parser.spec.ts index e99a5b05..fda1707c 100644 --- a/tests/unit/stages/review/agentic/result-parser.spec.ts +++ b/tests/unit/stages/review/agentic/result-parser.spec.ts @@ -206,6 +206,14 @@ describe('parseIssuesFromResult', () => { expect(result).toHaveLength(1); expect(result[0].description).toBe('Issue 1'); }); + + it('parses issues from prose + unclosed fenced code block (truncated model response)', () => { + const prose = + 'I have enough information. Let me reason through each file carefully.\n\n```json\n[{"type":"bug","severity":"high","description":"Mismatch","location":"src/foo.ts:1","confidence":8},{"type":"security","severity":"critical","description":"Truncated'; + const result = parseIssuesFromResult(prose, files, 'job', CWD); + expect(result).toHaveLength(1); + expect(result[0].description).toBe('Mismatch'); + }); }); describe('recoverPartialJsonArray', () => { @@ -227,4 +235,14 @@ describe('recoverPartialJsonArray', () => { const input = '[{"a":{"nested":1}},{"b":2},{"c":"trunc'; expect(recoverPartialJsonArray(input)).toEqual([{ a: { nested: 1 } }, { b: 2 }]); }); + + it('recovers objects with trailing commas after fixing', () => { + const input = '[{"a":1,},{"b":2,}]'; + expect(recoverPartialJsonArray(input)).toEqual([{ a: 1 }, { b: 2 }]); + }); + + it('recovers objects with raw newlines inside string values', () => { + const input = '[{"a":"line1\nline2","b":1}]'; + expect(recoverPartialJsonArray(input)).toEqual([{ a: 'line1\nline2', b: 1 }]); + }); });