Repository navigation
fix(opencode): accept answer_commit list inputs sent as JSON strings #1701
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a90c977
a7d958e
2cde866
4795597
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -183,13 +183,45 @@ export type AnswerCommitInput = z.input<typeof parameters> | |
|
|
||
| // Tolerate boolean aliases at runtime while advertising the canonical boolean schema. | ||
| // Only answer_only is boolean; answer values and table cells must remain untouched. | ||
| const parser = z.preprocess((input) => { | ||
| if (!input || typeof input !== "object" || Array.isArray(input) || !("answer_only" in input)) return input | ||
| const value = typeof input.answer_only === "string" ? input.answer_only.toLowerCase() : input.answer_only | ||
| const answer_only = | ||
| value === "true" || value === 1 ? true : value === "false" || value === 0 ? false : input.answer_only | ||
| return { ...input, answer_only } | ||
| }, parameters) | ||
| // altimate_change start — #1700: some models (e.g. Altimate Base) send list/object inputs as | ||
| // JSON-encoded strings. A string is never valid for these keys, so decode it when it parses as | ||
| // JSON and let the schema validate the result as usual; anything else is left for the normal error. | ||
| const JSON_SHAPED_KEYS = ["fields", "asked", "rows", "unresolved", "readings"] as const | ||
|
|
||
| /** Parse a string that looks like a JSON array or object; return any other value, or invalid JSON, unchanged. */ | ||
| function decodeJsonShaped(value: unknown): unknown { | ||
| if (typeof value !== "string" || !/^\s*[[{]/.test(value)) return value | ||
| try { | ||
| return JSON.parse(value) | ||
| } catch { | ||
| return value | ||
| } | ||
| } | ||
| // altimate_change end | ||
|
|
||
| // altimate_change start — #1700: exported so raw-argument readers (MCP adapter/server) see the same decoded shape | ||
| /** | ||
| * Normalize raw answer_commit arguments before validation: decode JSON-string list/object inputs | ||
| * and coerce string or numeric `answer_only` flags. Non-object input is returned unchanged. | ||
| */ | ||
| export function normalizeAnswerArgs(input: unknown): unknown { | ||
| if (!input || typeof input !== "object" || Array.isArray(input)) return input | ||
| const next: Record<string, unknown> = { ...input } | ||
| for (const key of JSON_SHAPED_KEYS) { | ||
| if (key in next) next[key] = decodeJsonShaped(next[key]) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| if (!("answer_only" in next)) return next | ||
| const value = typeof next.answer_only === "string" ? next.answer_only.toLowerCase() : next.answer_only | ||
| if (value === "true" || value === 1) { | ||
| next.answer_only = true | ||
| } else if (value === "false" || value === 0) { | ||
| next.answer_only = false | ||
| } | ||
| return next | ||
| } | ||
|
|
||
| const parser = z.preprocess(normalizeAnswerArgs, parameters) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| // altimate_change end | ||
|
|
||
| function validationError(error: z.ZodError): string { | ||
| const describe = (issues: z.core.$ZodIssue[], parent: PropertyKey[] = []): string[] => | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,6 +50,79 @@ describe("answer_commit boolean tolerance", () => { | |
| }) | ||
| }) | ||
|
|
||
| // altimate_change start — #1700: some models (e.g. Altimate Base) send list/object inputs as | ||
| // JSON-encoded strings. Every commit was rejected with "fields: Invalid input: expected array, | ||
| // received string", the model retried with the same shape, and the structured answer was lost. | ||
| describe("answer_commit JSON-string tolerance (#1700)", () => { | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
ekkundia-build marked this conversation as resolved.
Resolved
|
||
| const basis = "Counted orders at order grain using stg_orders.order_id." | ||
| const fields = [{ name: "Is anything wrong", value: "Yes, a join fan-out inflates number_of_orders" }] | ||
|
|
||
| test("fields sent as a JSON string renders the same answer as a real array", async () => { | ||
| const expected = AnswerCommit.renderAnswer({ basis, fields }) | ||
| const payload = { basis, fields: JSON.stringify(fields) } | ||
| expect(AnswerCommit.renderAnswer(payload)).toBe(expected) | ||
| const tool = await initTool(AnswerCommit.AnswerCommitTool) | ||
| expect((await tool.execute(payload, context)).output).toBe(expected) | ||
| }) | ||
|
|
||
| test("the exact shape from the #1700 trace is accepted", async () => { | ||
| // Pretty-printed with a space after the colon, as the model sent it. | ||
| const payload = { | ||
| basis, | ||
| asked: '["is anything wrong"]', | ||
| fields: '[{"name": "diagnosis", "value": 1}]', | ||
| } | ||
| const tool = await initTool(AnswerCommit.AnswerCommitTool) | ||
| expect((await tool.execute(payload, context)).output).toBe(`${basis}\n\n**Answer:** diagnosis: 1`) | ||
| }) | ||
|
|
||
| test("rows and unresolved sent as JSON strings are accepted", async () => { | ||
| const rows = { columns: ["customer_id", "orders"], values: [[51, 3]] } | ||
| expect(AnswerCommit.renderAnswer({ basis, rows: JSON.stringify(rows) })).toBe( | ||
| AnswerCommit.renderAnswer({ basis, rows }), | ||
| ) | ||
| const unresolved = { reason: "The warehouse was unreachable." } | ||
| expect(AnswerCommit.renderAnswer({ basis, unresolved: JSON.stringify(unresolved) })).toBe( | ||
| AnswerCommit.renderAnswer({ basis, unresolved }), | ||
| ) | ||
| }) | ||
|
|
||
| test("readings and asked sent as JSON strings are decoded into metadata", async () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| const readings = [{ clause: "order counts", reading: "distinct orders", result_summary: "99", decided_by: "request" }] | ||
| const tool = await initTool(AnswerCommit.AnswerCommitTool) | ||
| const result = await tool.execute( | ||
| { basis, fields, asked: '["Is anything wrong"]', readings: JSON.stringify(readings) }, | ||
| context, | ||
| ) | ||
| expect(result.metadata.readings).toEqual(readings) | ||
| expect(result.metadata.asked).toEqual(["Is anything wrong"]) | ||
| expect(result.output).toBe(AnswerCommit.renderAnswer({ basis, fields })) | ||
| }) | ||
|
|
||
| test("a JSON string that is not valid JSON is still rejected with the normal error", async () => { | ||
| const payload = { basis, fields: '[{"name": "diagnosis", "value": 1}' } | ||
| expect(() => AnswerCommit.renderAnswer(payload)).toThrow(/fields.*expected array/i) | ||
| const tool = await initTool(AnswerCommit.AnswerCommitTool) | ||
| await rejects(tool.execute(payload, context), /fields.*expected array/i) | ||
| }) | ||
|
|
||
| test("a plain string that does not look like JSON is still rejected", () => { | ||
| expect(() => AnswerCommit.renderAnswer({ basis, fields: "Yes, a join fan-out" })).toThrow(/fields.*expected array/i) | ||
| }) | ||
|
|
||
| test("decoded values still go through full validation", () => { | ||
| expect(() => AnswerCommit.renderAnswer({ basis, fields: '[{"name": "x", "value": ""}]' })).toThrow( | ||
| /non-empty/i, | ||
| ) | ||
| }) | ||
|
|
||
| test("string answer values that look like JSON are left untouched", () => { | ||
| const value = '[{"looks": "like json"}]' | ||
| expect(AnswerCommit.renderAnswer({ basis, fields: [{ name: "Raw", value }] })).toContain(value) | ||
| }) | ||
| }) | ||
| // altimate_change end | ||
|
|
||
| describe("answer_commit required basis", () => { | ||
| test.each([undefined, false, "false", 0, null, "", [], {}, true, "true", "TRUE", "TrUe", 1].map((value) => [value]))( | ||
| "answer_only %j requires basis for every answer shape", | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.