From eba03c9c366a06f201aeab6221704cc93cebefe0 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 13:20:27 -0700 Subject: [PATCH 01/39] feat(analyst): `answer_commit` renders the final answer deterministically The analyst's final message was free prose shaped by a prompt paragraph, so the same computed values could be presented differently from run to run. `answer_commit` is an ordinary tool, available only to the analyst agent, that takes the single chosen value(s) (`fields`), an N-row table (`rows`) or `unresolved` with a reason, plus an optional two-sentence basis and `answer_only` flag, validates the payload structurally (one value per field, no empty values, rectangular rows, precision applied, at most two basis sentences, strict-schema fillers treated as absent) and returns the exact final text to reply with. An advisory `readings` ledger (clause, reading, result summary, decision) is kept in tool metadata and never rendered. - Session mechanics, message rendering, loop termination and tracing are unchanged; the tool only returns text. - Registered on the analyst-only gated candidate list, so builder, plan, data-explorer and custom agents cannot see it and `tool_lookup` does not list it; `ALTIMATE_ANSWER_COMMIT=0` removes it. - The analyst prompt's final paragraph now asks to finish by calling `answer_commit` and replying with exactly the returned text, with the previous prose contract kept as the fallback when the tool is unavailable. - Not covered: automatic removal in user JSON-schema mode (no hook exists where `StructuredOutput` is injected); documented in the tool description. Tests: 48 renderer/validation/tool cases, 4 registration and scoping cases, prompt-contract assertion updated. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/agent/agent.ts | 1 + .../opencode/src/altimate/prompts/analyst.txt | 2 +- .../src/altimate/tools/answer-commit.ts | 154 +++++++++++ packages/opencode/src/tool/registry.ts | 6 + .../opencode/test/agent/answer-commit.test.ts | 106 ++++++++ .../test/altimate/sql-validation-e2e.test.ts | 9 +- .../test/altimate/tools/answer-commit.test.ts | 240 ++++++++++++++++++ 7 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/altimate/tools/answer-commit.ts create mode 100644 packages/opencode/test/agent/answer-commit.test.ts create mode 100644 packages/opencode/test/altimate/tools/answer-commit.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8b8724f686..2186274123 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -336,6 +336,7 @@ export const layer = Layer.effect( Permission.fromConfig({ "*": "deny", ...readOnlyDataPermission, + answer_commit: "allow", webfetch: "allow", websearch: "allow", question: "allow", diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index 71c9006ddd..82164075f3 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -18,4 +18,4 @@ You are a trainable AI teammate. Check the "Teammate Training" section in your s If the user corrects your behavior, offer to save it as a rule using `training_save`. Use `training_list` to review learned knowledge. Skills: /teach, /train, /training-status. -Finish with a delimited answer: one or two sentences of basis (population, grain, caveats), then the final line `**Answer:** `. The final message is the deliverable: keep working notes in earlier turns, and never list candidates you excluded or alternative answers under other interpretations — pick one and state it. When the request asks for only the answer or a specific format, the final message is exactly that, with no basis. A top-N with a tie at the boundary still returns N concrete rows (note the tie briefly). If unresolved, use `**Answer:** unable to determine — `; never guess. +Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else. Never list excluded candidates or alternative answers; when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; never guess. diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts new file mode 100644 index 0000000000..227eddb794 --- /dev/null +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -0,0 +1,154 @@ +import z from "zod" +import { Tool } from "../../tool/tool" + +// Strict-schema callers fill unused optional properties with "", [] or {}. +// Only optional properties accept these fillers; required values still validate. +function optional(schema: T) { + return z + .union([schema, z.literal(""), z.tuple([]), z.object({}).strict()]) + .optional() + .transform((value) => { + if (value === "" || value === undefined) return undefined + if (Array.isArray(value) && value.length === 0) return undefined + if (typeof value === "object" && value !== null && Object.keys(value).length === 0) return undefined + return value as z.output + }) +} + +const field = z + .object({ + name: z.string().describe("Field label; use an empty name for an unnamed value"), + value: z + .union([z.string(), z.number()], { error: "Each field value must be one non-empty string or number" }) + .refine((value) => typeof value !== "string" || value.trim().length > 0, "Field value must be non-empty"), + units: optional(z.string()), + precision: optional(z.number().int().min(0).max(10)).describe( + "Decimal places for numeric values; integers stay integers", + ), + }) + .strict() + +const rows = z + .object({ + columns: z.array(z.string()).min(1, "rows.columns requires at least one column"), + values: z + .array(z.array(z.union([z.string(), z.number(), z.null()]))) + .min(1, "rows.values requires at least one row"), + note: optional(z.string()), + }) + .strict() + .superRefine((rows, ctx) => { + rows.values.forEach((row, index) => { + if (row.length !== rows.columns.length) { + ctx.addIssue({ + code: "custom", + path: ["values", index], + message: `Row ${index} must have exactly ${rows.columns.length} cells; received ${row.length}`, + }) + } + }) + }) + +const parameters = z + .object({ + fields: optional(z.array(field)), + rows: optional(rows), + unresolved: optional( + z + .object({ + reason: z.string().refine((value) => value.trim().length > 0, "unresolved.reason must be non-empty"), + }) + .strict(), + ), + basis: optional( + z + .string() + .refine( + (value) => value.split(/[.?!](?:\s+|$)/).filter((part) => part.trim()).length <= 2, + "basis must contain at most 2 sentences", + ), + ), + answer_only: optional(z.boolean()), + readings: optional( + z.array( + z + .object({ + clause: z.string(), + reading: z.string(), + result_summary: z.string(), + decided_by: z.string(), + }) + .strict(), + ), + ).describe("Advisory readings recorded only in metadata, never in the answer text"), + }) + .strict() + .refine( + (value) => [value.fields, value.rows, value.unresolved].filter((shape) => shape !== undefined).length === 1, + "Exactly one of fields, rows, or unresolved is required", + ) + +export type AnswerCommitInput = z.input + +function validationError(error: z.ZodError): string { + const describe = (issues: z.core.$ZodIssue[], parent: PropertyKey[] = []): string[] => + issues.flatMap((issue) => { + const path = [...parent, ...issue.path] + // The first union member is the actual value schema; the others are fillers. + if (issue.code === "invalid_union" && issue.errors[0]?.length && !issue.message.includes("Each field value")) + return describe(issue.errors[0], path) + return [`${path.join(".") || "answer_commit"}: ${issue.message}`] + }) + return describe(error.issues).join("; ") +} + +function tableCell(value: string | number | null): string { + return String(value).replaceAll("|", "\\|").replace(/\r?\n/g, "
") +} + +/** Deterministically finalize a data-question answer without changing session state. */ +export function renderAnswer(payload: unknown): string { + const parsed = parameters.safeParse(payload) + if (!parsed.success) throw new Error(validationError(parsed.error)) + const args = parsed.data + const basis = !args.answer_only && args.basis ? `${args.basis}\n\n` : "" + if (args.fields) { + const values = args.fields + .map((field) => { + const value = + typeof field.value === "number" && field.precision !== undefined && !Number.isInteger(field.value) + ? field.value.toFixed(field.precision) + : String(field.value) + const name = !args.answer_only && field.name ? `${field.name}: ` : "" + return `${name}${value}${field.units ? ` ${field.units}` : ""}` + }) + .join("; ") + return args.answer_only ? values : `${basis}**Answer:** ${values}` + } + if (args.rows) { + const row = (cells: (string | number | null)[]) => `| ${cells.map(tableCell).join(" | ")} |` + const table = [ + row(args.rows.columns), + `|${args.rows.columns.map(() => "---").join("|")}|`, + ...args.rows.values.map(row), + ].join("\n") + return args.answer_only ? table : `${basis}**Answer:**\n${table}${args.rows.note ? `\n${args.rows.note}` : ""}` + } + return `${basis}**Answer:** unable to determine — ${args.unresolved!.reason}` +} + +export const AnswerCommitTool = Tool.define("answer_commit", { + description: + "Deterministically finalize a data-question answer. Supply exactly one of fields, rows, or unresolved. Use answer_only for a request for only the answer; leave unused properties empty. Return the rendered answer exactly as instructed.", + parameters, + formatValidationError: validationError, + async execute(args, ctx) { + if (ctx.agent !== "analyst") throw new Error("answer_commit is only available to the analyst agent") + await ctx.ask({ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }) + return { + title: "Answer committed", + metadata: { readings: args.readings ?? [], truncated: false }, + output: `${renderAnswer(args)}\n\nReply with exactly the text above and nothing else.`, + } + }, +}) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index e0ffd2ad36..4957fc2925 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -58,6 +58,7 @@ import { pathToFileURL } from "url" import { SqlExecuteTool } from "../altimate/tools/sql-execute" import { SqlMaterializeTool } from "../altimate/tools/sql-materialize" import { SqlCheckTool } from "../altimate/tools/sql-check" +import { AnswerCommitTool } from "../altimate/tools/answer-commit" import { SchemaInspectTool } from "../altimate/tools/schema-inspect" import { SqlAnalyzeTool } from "../altimate/tools/sql-analyze" import { SqlOptimizeTool } from "../altimate/tools/sql-optimize" @@ -542,6 +543,11 @@ export namespace ToolRegistry { ) { const tools = await all(plugins, configInput) // altimate_change end + // altimate_change start — analyst-only finalization is a gated candidate, never a tool_lookup catalogue entry. + if (agent?.name === "analyst" && process.env.ALTIMATE_ANSWER_COMMIT !== "0") { + tools.push(await AppRuntime.runPromise(AnswerCommitTool)) + } + // altimate_change end const result = await Promise.all( tools .filter((t) => { diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts new file mode 100644 index 0000000000..9317896b59 --- /dev/null +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { Agent } from "../../src/agent/agent" +import { ToolLookupTool } from "../../src/altimate/tools/tool-lookup" +import { Permission } from "../../src/permission" +import { Instance } from "../../src/project/instance" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { MessageID, SessionID } from "../../src/session/schema" +import { ToolRegistry } from "../../src/tool/registry" +import { initTool } from "../altimate/tool-fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +const model = { providerID: ProviderID.opencode, modelID: ModelID.make("test") } +let previousToggle: string | undefined + +beforeEach(() => { + previousToggle = process.env.ALTIMATE_ANSWER_COMMIT + delete process.env.ALTIMATE_ANSWER_COMMIT +}) + +afterEach(async () => { + if (previousToggle === undefined) delete process.env.ALTIMATE_ANSWER_COMMIT + else process.env.ALTIMATE_ANSWER_COMMIT = previousToggle + await disposeAllInstances() +}) + +describe("answer_commit agent scope", () => { + test("analyst permits and receives answer_commit", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const analyst = (await Agent.get("analyst"))! + expect(Permission.evaluate("answer_commit", "*", analyst.permission).action).toBe("allow") + const tools = await ToolRegistry.tools(model, analyst) + expect(tools.filter((tool) => tool.id === "answer_commit")).toHaveLength(1) + }, + }) + }) + + test("builder, plan, data-explorer and custom agents never receive answer_commit", async () => { + await using tmp = await tmpdir({ + config: { + permission: { "*": "allow", answer_commit: "allow" }, + agent: { + custom: { + description: "A custom agent", + mode: "primary", + permission: { "*": "allow", answer_commit: "allow" }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + for (const name of ["builder", "build", "plan", "data-explorer", "custom"]) { + const agent = await Agent.get(name) + expect(agent).toBeDefined() + const tools = await ToolRegistry.tools(model, agent) + expect(tools.map((tool) => tool.id)).not.toContain("answer_commit") + } + expect((await ToolRegistry.tools(model)).map((tool) => tool.id)).not.toContain("answer_commit") + }, + }) + }) + + test("tool_lookup does not expose answer_commit to builder", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await initTool(ToolLookupTool) + const ctx = { + sessionID: SessionID.make("ses_answer_commit"), + messageID: MessageID.make("msg_answer_commit"), + callID: "call_answer_commit_lookup", + agent: "builder", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + } + const missing = await tool.execute({ tool_name: "answer_commit" }, ctx) + expect(missing.title).toBe("Tool not found") + const listing = await tool.execute({ tool_name: "__missing_tool" }, ctx) + expect(listing.output).not.toContain("answer_commit") + expect((await ToolRegistry.allInfos()).map((info) => info.id)).not.toContain("answer_commit") + expect(await ToolRegistry.ids()).not.toContain("answer_commit") + }, + }) + }) + + test("ALTIMATE_ANSWER_COMMIT=0 removes the analyst candidate and can be toggled back on", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const analyst = (await Agent.get("analyst"))! + process.env.ALTIMATE_ANSWER_COMMIT = "0" + expect((await ToolRegistry.tools(model, analyst)).map((tool) => tool.id)).not.toContain("answer_commit") + process.env.ALTIMATE_ANSWER_COMMIT = "1" + expect((await ToolRegistry.tools(model, analyst)).map((tool) => tool.id)).toContain("answer_commit") + }, + }) + }) +}) diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index 62b1c22453..fde4217279 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -93,7 +93,7 @@ describe("Tool name consistency in prompts", () => { fn: async () => { const analyst = await Agent.get("analyst") expect(analyst).toBeDefined() - for (const name of ["sql_analyze", "sql_materialize", "task", "skill", "read"]) { + for (const name of ["sql_analyze", "sql_materialize", "answer_commit", "task", "skill", "read"]) { expect(analyst!.prompt).toContain(name) } expect(analyst!.prompt).not.toContain("altimate_core_validate") @@ -101,6 +101,13 @@ describe("Tool name consistency in prompts", () => { }) }) + test("analyst prompt requires deterministic answer finalization and an unavailable-tool fallback", async () => { + const prompt = await Bun.file(path.join(import.meta.dir, "../../src/altimate/prompts/analyst.txt")).text() + expect(prompt.trim().split("\n\n").at(-1)).toBe( + "Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else. Never list excluded candidates or alternative answers; when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; never guess.", + ) + }) + test("builder prompt contains pre-execution protocol with correct tool names", async () => { await using tmp = await tmpdir() await Instance.provide({ diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts new file mode 100644 index 0000000000..6fb01c8703 --- /dev/null +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, test } from "bun:test" +import { AnswerCommitTool, renderAnswer } from "../../../src/altimate/tools/answer-commit" +import { initTool } from "../tool-fixture" + +describe("renderAnswer golden text", () => { + const cases: [string, unknown, string][] = [ + ["single unnamed field", { fields: [{ name: "", value: "West" }] }, "**Answer:** West"], + ["single named field", { fields: [{ name: "Region", value: "West" }] }, "**Answer:** Region: West"], + [ + "basis and units", + { basis: "Summed completed orders.", fields: [{ name: "Revenue", value: 42, units: "USD" }] }, + "Summed completed orders.\n\n**Answer:** Revenue: 42 USD", + ], + [ + "multiple fields", + { + fields: [ + { name: "Orders", value: 3 }, + { name: "Revenue", value: 12.5, units: "USD" }, + ], + }, + "**Answer:** Orders: 3; Revenue: 12.5 USD", + ], + ["fixed numeric precision", { fields: [{ name: "Rate", value: 1.2, precision: 3 }] }, "**Answer:** Rate: 1.200"], + ["zero precision", { fields: [{ name: "Rate", value: 1.6, precision: 0 }] }, "**Answer:** Rate: 2"], + ["maximum precision", { fields: [{ name: "", value: 0.5, precision: 10 }] }, "**Answer:** 0.5000000000"], + ["integers stay integers", { fields: [{ name: "", value: 7, precision: 2 }] }, "**Answer:** 7"], + ["zero is a value", { fields: [{ name: "", value: 0, precision: 2 }] }, "**Answer:** 0"], + ["precision leaves text intact", { fields: [{ name: "", value: "001.20", precision: 1 }] }, "**Answer:** 001.20"], + [ + "answer_only single field", + { + basis: "This basis is omitted.", + answer_only: true, + fields: [{ name: "Revenue", value: 1.2, units: "USD", precision: 2 }], + }, + "1.20 USD", + ], + [ + "answer_only multiple fields", + { + answer_only: true, + fields: [ + { name: "Orders", value: 3 }, + { name: "Revenue", value: 12, units: "USD" }, + ], + }, + "3; 12 USD", + ], + [ + "rows without basis", + { + rows: { + columns: ["Region", "Orders"], + values: [ + ["West", 3], + ["East", 2], + ], + }, + }, + "**Answer:**\n| Region | Orders |\n|---|---|\n| West | 3 |\n| East | 2 |", + ], + [ + "rows with basis and note", + { + basis: "Grouped completed orders.", + rows: { columns: ["Region", "Orders"], values: [["West", 3]], note: "Includes all regions." }, + }, + "Grouped completed orders.\n\n**Answer:**\n| Region | Orders |\n|---|---|\n| West | 3 |\nIncludes all regions.", + ], + [ + "answer_only rows omit basis and note", + { + answer_only: true, + basis: "Grouped completed orders.", + rows: { columns: ["Orders"], values: [[3]], note: "This note is omitted." }, + }, + "| Orders |\n|---|\n| 3 |", + ], + [ + "null and empty table cells", + { rows: { columns: ["Value"], values: [[null], [""]] } }, + "**Answer:**\n| Value |\n|---|\n| null |\n| |", + ], + [ + "table cells preserve their boundaries", + { rows: { columns: ["A|B"], values: [["first\nsecond|third"]] } }, + "**Answer:**\n| A\\|B |\n|---|\n| first
second\\|third |", + ], + [ + "unresolved without basis", + { unresolved: { reason: "No matching rows." } }, + "**Answer:** unable to determine — No matching rows.", + ], + [ + "unresolved with basis", + { basis: "Checked the requested period.", unresolved: { reason: "No matching rows." } }, + "Checked the requested period.\n\n**Answer:** unable to determine — No matching rows.", + ], + [ + "answer_only unresolved retains its status", + { answer_only: true, basis: "Checked the requested period.", unresolved: { reason: "No matching rows." } }, + "**Answer:** unable to determine — No matching rows.", + ], + [ + "two sentences and a decimal", + { basis: "The rate is 1.25. There are two orders!", fields: [{ name: "", value: 2 }] }, + "The rate is 1.25. There are two orders!\n\n**Answer:** 2", + ], + [ + "unpunctuated basis", + { basis: "From completed orders", fields: [{ name: "", value: 2 }] }, + "From completed orders\n\n**Answer:** 2", + ], + [ + "unused object and string fillers", + { + fields: [{ name: "", value: 2, units: "", precision: "" }], + rows: {}, + unresolved: "", + basis: "", + readings: [], + answer_only: "", + }, + "**Answer:** 2", + ], + [ + "unused array fillers", + { fields: [], rows: { columns: ["Count"], values: [[2]], note: "" }, unresolved: {}, basis: [], readings: "" }, + "**Answer:**\n| Count |\n|---|\n| 2 |", + ], + [ + "advisory readings do not render", + { + fields: [{ name: "", value: 2 }], + readings: [{ clause: "month", reading: "calendar month", result_summary: "2", decided_by: "request" }], + }, + "**Answer:** 2", + ], + ] + + test.each(cases)("%s", (_name, payload, expected) => { + expect(renderAnswer(payload)).toBe(expected) + }) +}) + +describe("renderAnswer structural validation", () => { + test.each([ + ["empty value", { fields: [{ name: "Count", value: "" }] }, /fields.*value.*non-empty/i], + ["blank value", { fields: [{ name: "Count", value: " " }] }, /fields.*value.*non-empty/i], + ["multiple values in one field", { fields: [{ name: "Count", value: [1, 2] }] }, /fields.*value.*one/i], + ["missing value", { fields: [{ name: "Count" }] }, /fields.*value/i], + ["null field value", { fields: [{ name: "Count", value: null }] }, /fields.*value/i], + ["ragged rows", { rows: { columns: ["A", "B"], values: [[1, 2], [3]] } }, /rows.*1.*2.*cells/i], + ["no columns", { rows: { columns: [], values: [[1]] } }, /rows.*columns/i], + ["no rows", { rows: { columns: ["A"], values: [] } }, /rows.*values/i], + ["three sentences", { basis: "First. Second? Third!", fields: [{ name: "", value: 1 }] }, /basis.*2 sentences/i], + [ + "unpunctuated third sentence", + { basis: "First. Second! Third", fields: [{ name: "", value: 1 }] }, + /basis.*2 sentences/i, + ], + ["negative precision", { fields: [{ name: "", value: 1.5, precision: -1 }] }, /precision/i], + ["excessive precision", { fields: [{ name: "", value: 1.5, precision: 11 }] }, /precision/i], + ["fractional precision", { fields: [{ name: "", value: 1.5, precision: 1.5 }] }, /precision/i], + ["empty reason", { unresolved: { reason: "" } }, /unresolved.*reason/i], + ["no answer shape", {}, /exactly one of fields, rows, or unresolved/i], + ["only fillers", { fields: [], rows: {}, unresolved: "" }, /exactly one of fields, rows, or unresolved/i], + [ + "fields and rows", + { fields: [{ name: "", value: 1 }], rows: { columns: ["A"], values: [[1]] } }, + /exactly one of fields, rows, or unresolved/i, + ], + [ + "fields and unresolved", + { fields: [{ name: "", value: 1 }], unresolved: { reason: "Missing" } }, + /exactly one of fields, rows, or unresolved/i, + ], + [ + "rows and unresolved", + { rows: { columns: ["A"], values: [[1]] }, unresolved: { reason: "Missing" } }, + /exactly one of fields, rows, or unresolved/i, + ], + ] as [string, unknown, RegExp][])("rejects %s", (_name, payload, error) => { + expect(() => renderAnswer(payload)).toThrow(error) + }) +}) + +const context = { + sessionID: "test-session", + messageID: "test-message", + agent: "analyst", + abort: new AbortController().signal, + messages: [], +} + +test("answer_commit returns exact reply instructions and advisory metadata", async () => { + const tool = await initTool(AnswerCommitTool) + const readings = [{ clause: "month", reading: "calendar month", result_summary: "2", decided_by: "request" }] + const asks: unknown[] = [] + const result = await tool.execute( + { fields: [{ name: "", value: 2 }], rows: {}, unresolved: "", basis: "", readings }, + { + ...context, + ask: (input: unknown) => { + asks.push(input) + }, + }, + ) + expect(result.output).toBe("**Answer:** 2\n\nReply with exactly the text above and nothing else.") + expect(result.metadata.readings).toEqual(readings) + expect(asks).toEqual([{ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }]) +}) + +test("answer_commit exposes a serializable object input schema", async () => { + const tool = await initTool(AnswerCommitTool) + expect(tool.jsonSchema?.type).toBe("object") + expect(Object.keys(tool.jsonSchema?.properties ?? {})).toEqual([ + "fields", + "rows", + "unresolved", + "basis", + "answer_only", + "readings", + ]) +}) + +test("answer_commit names invalid field values through the tool wrapper", async () => { + const tool = await initTool(AnswerCommitTool) + await expect(tool.execute({ fields: [{ name: "Count", value: "" }] }, context)).rejects.toThrow( + /fields.*value.*non-empty/i, + ) +}) + +test("answer_commit refuses direct execution by another agent", async () => { + const tool = await initTool(AnswerCommitTool) + await expect(tool.execute({ fields: [{ name: "", value: 2 }] }, { ...context, agent: "builder" })).rejects.toThrow( + /only.*analyst/i, + ) +}) From 1ac8ff6196ade7eb19793a16960d1273e001f059 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 13:37:24 -0700 Subject: [PATCH 02/39] fix(analyst): `answer_commit` description, filler tolerance, escaping and sentence counting Review follow-ups: - The tool description says when to call it (last tool call, once the answer is decided), that the returned text is the verbatim final reply, what `answer_only` means, and that a user output schema takes precedence. - The prompt's unavailable-tool fallback restores the answer-only rule. - `null` and whitespace-only values for optional fields are treated as absent; whitespace-only `value`/`name` is the existing empty-value error. - Table cells escape `|` and a preceding backslash; newlines become spaces. - The two-sentence basis cap ignores common abbreviations and decimals. - Tests cover the above, a custom agent named `analyst` (receives the tool by design) and a same-id plugin tool. Co-Authored-By: Claude Fable 5.1 --- .../opencode/src/altimate/prompts/analyst.txt | 2 +- .../src/altimate/tools/answer-commit.ts | 32 ++++-- packages/opencode/src/tool/registry.ts | 3 + .../opencode/test/agent/answer-commit.test.ts | 77 +++++++++++++ .../test/altimate/sql-validation-e2e.test.ts | 2 +- .../test/altimate/tools/answer-commit.test.ts | 106 +++++++++++++++++- 6 files changed, 207 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index 82164075f3..952005f5c3 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -18,4 +18,4 @@ You are a trainable AI teammate. Check the "Teammate Training" section in your s If the user corrects your behavior, offer to save it as a rule using `training_save`. Use `training_list` to review learned knowledge. Skills: /teach, /train, /training-status. -Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else. Never list excluded candidates or alternative answers; when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; never guess. +Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else. Never list excluded candidates or alternative answers; when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly that, with no basis; never guess. diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 227eddb794..aa8cb9c12e 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -1,14 +1,14 @@ import z from "zod" import { Tool } from "../../tool/tool" -// Strict-schema callers fill unused optional properties with "", [] or {}. +// Strict-schema callers fill unused optional properties with null, "", [] or {}. // Only optional properties accept these fillers; required values still validate. function optional(schema: T) { return z - .union([schema, z.literal(""), z.tuple([]), z.object({}).strict()]) + .union([schema, z.null(), z.literal(""), z.tuple([]), z.object({}).strict()]) .optional() .transform((value) => { - if (value === "" || value === undefined) return undefined + if (value === null || value === undefined || (typeof value === "string" && !value.trim())) return undefined if (Array.isArray(value) && value.length === 0) return undefined if (typeof value === "object" && value !== null && Object.keys(value).length === 0) return undefined return value as z.output @@ -17,7 +17,10 @@ function optional(schema: T) { const field = z .object({ - name: z.string().describe("Field label; use an empty name for an unnamed value"), + name: z + .string() + .refine((value) => value === "" || value.trim().length > 0, "Field name must be non-empty") + .describe("Field label; use an empty name for an unnamed value"), value: z .union([z.string(), z.number()], { error: "Each field value must be one non-empty string or number" }) .refine((value) => typeof value !== "string" || value.trim().length > 0, "Field value must be non-empty"), @@ -61,12 +64,14 @@ const parameters = z .strict(), ), basis: optional( - z - .string() - .refine( - (value) => value.split(/[.?!](?:\s+|$)/).filter((part) => part.trim()).length <= 2, - "basis must contain at most 2 sentences", - ), + z.string().refine( + (value) => + value + .replace(/\b(?:e\.g|i\.e|etc|Dr|vs|approx)\./gi, (word) => word.replaceAll(".", "")) + .split(/[.?!](?:\s+(?=\p{Lu})|$)/u) + .filter((part) => part.trim()).length <= 2, + "basis must contain at most 2 sentences", + ), ), answer_only: optional(z.boolean()), readings: optional( @@ -103,7 +108,10 @@ function validationError(error: z.ZodError): string { } function tableCell(value: string | number | null): string { - return String(value).replaceAll("|", "\\|").replace(/\r?\n/g, "
") + return String(value) + .replaceAll("\\", "\\\\") + .replaceAll("|", "\\|") + .replace(/\r\n|\r|\n/g, " ") } /** Deterministically finalize a data-question answer without changing session state. */ @@ -139,7 +147,7 @@ export function renderAnswer(payload: unknown): string { export const AnswerCommitTool = Tool.define("answer_commit", { description: - "Deterministically finalize a data-question answer. Supply exactly one of fields, rows, or unresolved. Use answer_only for a request for only the answer; leave unused properties empty. Return the rendered answer exactly as instructed.", + "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. Use the returned answer text verbatim as your final reply, following the reply instruction. Supply exactly one of fields, rows, or unresolved. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", parameters, formatValidationError: validationError, async execute(args, ctx) { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 4957fc2925..f6fb58a5d9 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -544,6 +544,9 @@ export namespace ToolRegistry { const tools = await all(plugins, configInput) // altimate_change end // altimate_change start — analyst-only finalization is a gated candidate, never a tool_lookup catalogue entry. + // A configured agent named analyst intentionally retains access. Append last so the session + // resolvers' tools[item.id] assignment gives the native tool precedence over custom/plugin collisions. + // MCP tools resolve afterward and can still replace this ID (e.g. server answer, tool commit). if (agent?.name === "analyst" && process.env.ALTIMATE_ANSWER_COMMIT !== "0") { tools.push(await AppRuntime.runPromise(AnswerCommitTool)) } diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 9317896b59..6c6f4db166 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" import { Agent } from "../../src/agent/agent" import { ToolLookupTool } from "../../src/altimate/tools/tool-lookup" import { Permission } from "../../src/permission" @@ -37,6 +38,82 @@ describe("answer_commit agent scope", () => { }) }) + test("a custom agent named analyst receives answer_commit by design", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + analyst: { + description: "Configured analyst", + prompt: "Use the configured analyst instructions.", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const analyst = (await Agent.get("analyst"))! + expect(analyst.description).toBe("Configured analyst") + expect(analyst.prompt).toBe("Use the configured analyst instructions.") + // Configuration customizes the native analyst; access intentionally follows its name. + expect(Permission.evaluate("answer_commit", "*", analyst.permission).action).toBe("allow") + const tools = await ToolRegistry.tools(model, analyst) + expect(tools.filter((tool) => tool.id === "answer_commit")).toHaveLength(1) + }, + }) + }) + + test("native answer_commit takes precedence over a plugin tool with the same id", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + let pluginCalls = 0 + const analyst = (await Agent.get("analyst"))! + const tools = await ToolRegistry.tools(model, analyst, [ + { + tool: { + answer_commit: { + description: "Plugin answer_commit collision", + args: {}, + execute: async () => { + pluginCalls += 1 + return "Plugin answer" + }, + }, + }, + }, + ]) + const candidates = tools.filter((tool) => tool.id === "answer_commit") + expect(candidates).toHaveLength(2) + expect(candidates[0].description).toBe("Plugin answer_commit collision") + expect(candidates[0].registrySource).toBe("external") + // Both session resolvers assign tools[item.id] in this order, so the last candidate wins. + const winner = Object.fromEntries(tools.map((tool) => [tool.id, tool])).answer_commit + expect(winner).toBe(candidates[1]) + expect(winner.registrySource).toBeUndefined() + const result = await Effect.runPromise( + winner.execute( + { fields: [{ name: "Revenue", value: 42 }] }, + { + sessionID: SessionID.make("ses_answer_commit_collision"), + messageID: MessageID.make("msg_answer_commit_collision"), + callID: "call_answer_commit_collision", + agent: "analyst", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ), + ) + expect(result.title).toBe("Answer committed") + expect(result.output).toContain("**Answer:** Revenue: 42") + expect(pluginCalls).toBe(0) + }, + }) + }) + test("builder, plan, data-explorer and custom agents never receive answer_commit", async () => { await using tmp = await tmpdir({ config: { diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index fde4217279..d50d0e37af 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -104,7 +104,7 @@ describe("Tool name consistency in prompts", () => { test("analyst prompt requires deterministic answer finalization and an unavailable-tool fallback", async () => { const prompt = await Bun.file(path.join(import.meta.dir, "../../src/altimate/prompts/analyst.txt")).text() expect(prompt.trim().split("\n\n").at(-1)).toBe( - "Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else. Never list excluded candidates or alternative answers; when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; never guess.", + "Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else. Never list excluded candidates or alternative answers; when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly that, with no basis; never guess.", ) }) diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index 6fb01c8703..b0ee411f35 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -85,7 +85,17 @@ describe("renderAnswer golden text", () => { [ "table cells preserve their boundaries", { rows: { columns: ["A|B"], values: [["first\nsecond|third"]] } }, - "**Answer:**\n| A\\|B |\n|---|\n| first
second\\|third |", + "**Answer:**\n| A\\|B |\n|---|\n| first second\\|third |", + ], + [ + "table headers and cells escape backslashes before pipes", + { rows: { columns: [String.raw`A\|B`, String.raw`C\\|D`], values: [[String.raw`a\|b`, String.raw`c\\|d`]] } }, + ["**Answer:**", String.raw`| A\\\|B | C\\\\\|D |`, "|---|---|", String.raw`| a\\\|b | c\\\\\|d |`].join("\n"), + ], + [ + "table headers and cells flatten all line endings to spaces", + { rows: { columns: ["A\r\nB\rC\nD"], values: [["a\r\nb\rc\nd"]] } }, + "**Answer:**\n| A B C D |\n|---|\n| a b c d |", ], [ "unresolved without basis", @@ -129,6 +139,13 @@ describe("renderAnswer golden text", () => { { fields: [], rows: { columns: ["Count"], values: [[2]], note: "" }, unresolved: {}, basis: [], readings: "" }, "**Answer:**\n| Count |\n|---|\n| 2 |", ], + ["whitespace-only basis is absent", { basis: " \t\r\n ", fields: [{ name: "", value: 2 }] }, "**Answer:** 2"], + ["whitespace-only units are absent", { fields: [{ name: "", value: 2, units: " \t\r\n " }] }, "**Answer:** 2"], + [ + "whitespace-only note is absent", + { rows: { columns: ["Count"], values: [[2]], note: " \t\r\n " } }, + "**Answer:**\n| Count |\n|---|\n| 2 |", + ], [ "advisory readings do not render", { @@ -144,10 +161,62 @@ describe("renderAnswer golden text", () => { }) }) +describe("renderAnswer strict-schema null fillers", () => { + test.each(["precision", "units"])("null %s is absent", (key) => { + expect(renderAnswer({ fields: [{ name: "", value: 1.25, [key]: null }] })).toBe("**Answer:** 1.25") + }) + + test.each(["basis", "readings", "answer_only"])("null %s is absent", (key) => { + expect(renderAnswer({ fields: [{ name: "Count", value: 2 }], [key]: null })).toBe("**Answer:** Count: 2") + }) + + test("null note is absent", () => { + expect(renderAnswer({ rows: { columns: ["Count"], values: [[2]], note: null } })).toBe( + "**Answer:**\n| Count |\n|---|\n| 2 |", + ) + }) +}) + +describe("renderAnswer basis sentence boundaries", () => { + test.each(["e.g.", "i.e.", "etc.", "Dr.", "vs.", "approx."])("does not count %s as a sentence end", (word) => { + const basis = `Used ${word} Completed orders. Count is 2.` + expect(renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) + }) + + test("does not count decimals as sentence ends", () => { + const basis = "The mean is 3.5 and the median is 2.5. Count is 2." + expect(renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) + }) + + test("punctuation followed by lowercase text is not a sentence end", () => { + const basis = "Count? maybe! still checking. Count is 2." + expect(renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) + }) + + test.each([".", "?", "!"])("caps sentences separated by %s and uppercase text", (punctuation) => { + const basis = `First${punctuation} Second${punctuation} Third${punctuation}` + expect(() => renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) + }) + + test("abbreviations and decimals do not hide a third sentence", () => { + expect(() => + renderAnswer({ basis: "Used e.g. Orders. Mean is 3.5. Count is 2.", fields: [{ name: "", value: 2 }] }), + ).toThrow(/basis.*2 sentences/i) + }) + + test("paragraph breaks count as sentence boundaries", () => { + expect(() => renderAnswer({ basis: "One.\n\nTwo.\n\nThree.", fields: [{ name: "", value: 2 }] })).toThrow( + /basis.*2 sentences/i, + ) + }) +}) + describe("renderAnswer structural validation", () => { test.each([ ["empty value", { fields: [{ name: "Count", value: "" }] }, /fields.*value.*non-empty/i], ["blank value", { fields: [{ name: "Count", value: " " }] }, /fields.*value.*non-empty/i], + ["whitespace-only value", { fields: [{ name: "Count", value: " \t\r\n " }] }, /fields.*value.*non-empty/i], + ["whitespace-only name", { fields: [{ name: " \t\r\n ", value: 2 }] }, /fields.*name.*non-empty/i], ["multiple values in one field", { fields: [{ name: "Count", value: [1, 2] }] }, /fields.*value.*one/i], ["missing value", { fields: [{ name: "Count" }] }, /fields.*value/i], ["null field value", { fields: [{ name: "Count", value: null }] }, /fields.*value/i], @@ -225,6 +294,41 @@ test("answer_commit exposes a serializable object input schema", async () => { ]) }) +test("answer_commit description defines final-call timing and verbatim reply", async () => { + const tool = await initTool(AnswerCommitTool) + expect(tool.description).toContain("LAST tool call") + expect(tool.description).toContain("once the answer is decided") + expect(tool.description).toContain("verbatim as your final reply") +}) + +test("answer_commit description defines answer_only and unused properties", async () => { + const tool = await initTool(AnswerCommitTool) + expect(tool.description).toContain("only the answer or a specific format") + expect(tool.description).toContain("exactly the value(s) or table, with no basis, field names, note, or answer label") + expect(tool.description).toContain("Omit unused optional properties") +}) + +test("answer_commit description gives the user output schema precedence", async () => { + const tool = await initTool(AnswerCommitTool) + expect(tool.description).toContain("not automatically removed when a user output schema is set") + expect(tool.description).toContain("the user schema takes precedence") +}) + +test("answer_commit accepts null optional fields through the tool wrapper", async () => { + const tool = await initTool(AnswerCommitTool) + const result = await tool.execute( + { + fields: [{ name: "", value: 1.25, precision: null, units: null }], + basis: null, + readings: null, + answer_only: null, + }, + context, + ) + expect(result.output).toBe("**Answer:** 1.25\n\nReply with exactly the text above and nothing else.") + expect(result.metadata.readings).toEqual([]) +}) + test("answer_commit names invalid field values through the tool wrapper", async () => { const tool = await initTool(AnswerCommitTool) await expect(tool.execute({ fields: [{ name: "Count", value: "" }] }, context)).rejects.toThrow( From e84cb9d1f49f988f654cf63bc895b6081ecefad7 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 13:50:04 -0700 Subject: [PATCH 03/39] fix(analyst): `answer_commit` returns only the answer text; sentence counting and precedence test tightened Independent-review follow-ups: - The tool output is exactly the rendered answer; the "reply with the returned text" instruction lives in the tool description, so the prompt and the tool no longer disagree about what to echo. - The basis sentence counter treats a boundary as `.`/`?`/`!` followed by whitespace (including newlines) and any non-space character, still ignoring common abbreviations and decimals; lowercase/newline cases tested. - The plugin-precedence test runs the real tool resolver instead of re-implementing its selection logic. Co-Authored-By: Claude Fable 5.1 --- .../src/altimate/tools/answer-commit.ts | 6 +- .../opencode/test/agent/answer-commit.test.ts | 153 +++++++++++++----- .../test/altimate/tools/answer-commit.test.ts | 29 +++- 3 files changed, 134 insertions(+), 54 deletions(-) diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index aa8cb9c12e..24538ec37c 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -68,7 +68,7 @@ const parameters = z (value) => value .replace(/\b(?:e\.g|i\.e|etc|Dr|vs|approx)\./gi, (word) => word.replaceAll(".", "")) - .split(/[.?!](?:\s+(?=\p{Lu})|$)/u) + .split(/[.?!](?:\s+(?=\S)|$)/u) .filter((part) => part.trim()).length <= 2, "basis must contain at most 2 sentences", ), @@ -147,7 +147,7 @@ export function renderAnswer(payload: unknown): string { export const AnswerCommitTool = Tool.define("answer_commit", { description: - "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. Use the returned answer text verbatim as your final reply, following the reply instruction. Supply exactly one of fields, rows, or unresolved. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", + "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", parameters, formatValidationError: validationError, async execute(args, ctx) { @@ -156,7 +156,7 @@ export const AnswerCommitTool = Tool.define("answer_commit", { return { title: "Answer committed", metadata: { readings: args.readings ?? [], truncated: false }, - output: `${renderAnswer(args)}\n\nReply with exactly the text above and nothing else.`, + output: renderAnswer(args), } }, }) diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 6c6f4db166..0f53abad8f 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -1,16 +1,25 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { Effect } from "effect" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { ToolLookupTool } from "../../src/altimate/tools/tool-lookup" +import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" +import { Plugin } from "../../src/plugin" import { Instance } from "../../src/project/instance" +import type { Provider } from "../../src/provider/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { MessageID, SessionID } from "../../src/session/schema" +import { SessionTools } from "../../src/session/tools" import { ToolRegistry } from "../../src/tool/registry" +import { Truncate } from "../../src/tool/truncate" import { initTool } from "../altimate/tool-fixture" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, TestInstance, tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { withLegacyInstance } from "../session/legacy-instance" const model = { providerID: ProviderID.opencode, modelID: ModelID.make("test") } +const it = testEffect(Layer.empty) let previousToggle: string | undefined beforeEach(() => { @@ -63,56 +72,114 @@ describe("answer_commit agent scope", () => { }) }) - test("native answer_commit takes precedence over a plugin tool with the same id", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { + it.instance("session resolver selects native answer_commit over a plugin tool with the same id", () => + withLegacyInstance( + Effect.gen(function* () { + const tmp = yield* TestInstance + const analyst = (yield* Effect.promise(() => Agent.get("analyst")))! let pluginCalls = 0 - const analyst = (await Agent.get("analyst"))! - const tools = await ToolRegistry.tools(model, analyst, [ - { - tool: { - answer_commit: { - description: "Plugin answer_commit collision", - args: {}, - execute: async () => { - pluginCalls += 1 - return "Plugin answer" + const plugin = Layer.mock(Plugin.Service, { + list: () => + Effect.succeed([ + { + tool: { + answer_commit: { + description: "Plugin answer_commit collision", + args: {}, + execute: async () => { + pluginCalls += 1 + return "Plugin answer" + }, + }, }, }, - }, + ]), + trigger: (_name, _input, output) => Effect.succeed(output), + }) + const resolverModel: Provider.Model = { + id: model.modelID, + providerID: model.providerID, + api: { id: model.modelID, url: "https://example.com", npm: "@ai-sdk/openai" }, + name: "Test model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + interleaved: false, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128000, output: 4096 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + } + const sessionID = SessionID.make("ses_answer_commit_collision") + const messageID = MessageID.make("msg_answer_commit_collision") + const tools = yield* SessionTools.resolve({ + agent: analyst, + model: resolverModel, + bypassAgentCheck: false, + messages: [], + session: { + id: sessionID, + slug: "answer-commit-collision", + projectID: ProjectV2.ID.make("project"), + directory: tmp.directory, + title: "Answer commit collision", + version: "1", + time: { created: 0, updated: 0 }, }, - ]) - const candidates = tools.filter((tool) => tool.id === "answer_commit") - expect(candidates).toHaveLength(2) - expect(candidates[0].description).toBe("Plugin answer_commit collision") - expect(candidates[0].registrySource).toBe("external") - // Both session resolvers assign tools[item.id] in this order, so the last candidate wins. - const winner = Object.fromEntries(tools.map((tool) => [tool.id, tool])).answer_commit - expect(winner).toBe(candidates[1]) - expect(winner.registrySource).toBeUndefined() - const result = await Effect.runPromise( - winner.execute( - { fields: [{ name: "Revenue", value: 42 }] }, - { - sessionID: SessionID.make("ses_answer_commit_collision"), - messageID: MessageID.make("msg_answer_commit_collision"), - callID: "call_answer_commit_collision", + processor: { + message: { + id: messageID, + sessionID, + role: "assistant", + time: { created: 0 }, + parentID: MessageID.make("msg_user"), + modelID: model.modelID, + providerID: model.providerID, + mode: "analyst", agent: "analyst", - abort: AbortSignal.any([]), - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, + path: { cwd: tmp.directory, root: tmp.directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, }, + updateToolCall: () => Effect.succeed(undefined), + completeToolCall: () => Effect.void, + }, + promptOps: { + resolvePromptParts: () => Effect.succeed([]), + prompt: () => Effect.die("unexpected prompt"), + cancel: () => Effect.void, + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + plugin, + ToolRegistry.layer.pipe(Layer.provide(plugin)), + Layer.mock(Permission.Service, { ask: () => Effect.void }), + Layer.mock(MCP.Service, { tools: () => Effect.succeed({}) }), + Layer.mock(Truncate.Service, {}), + ), + ), + ) + const result = yield* Effect.promise(() => + Promise.resolve( + tools.answer_commit.execute!( + { fields: [{ name: "Revenue", value: 42 }] }, + { toolCallId: "call_answer_commit_collision", messages: [], abortSignal: AbortSignal.any([]) }, + ), ), ) - expect(result.title).toBe("Answer committed") - expect(result.output).toContain("**Answer:** Revenue: 42") + expect(result).toMatchObject({ title: "Answer committed", output: "**Answer:** Revenue: 42" }) expect(pluginCalls).toBe(0) - }, - }) - }) + }), + ), + ) test("builder, plan, data-explorer and custom agents never receive answer_commit", async () => { await using tmp = await tmpdir({ diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index b0ee411f35..eddab91b9e 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -156,8 +156,10 @@ describe("renderAnswer golden text", () => { ], ] - test.each(cases)("%s", (_name, payload, expected) => { + test.each(cases)("%s", async (_name, payload, expected) => { expect(renderAnswer(payload)).toBe(expected) + const tool = await initTool(AnswerCommitTool) + expect((await tool.execute(payload, context)).output).toBe(expected) }) }) @@ -188,9 +190,20 @@ describe("renderAnswer basis sentence boundaries", () => { expect(renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) }) - test("punctuation followed by lowercase text is not a sentence end", () => { + test("punctuation followed by lowercase text counts as a sentence boundary", () => { const basis = "Count? maybe! still checking. Count is 2." - expect(renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) + expect(() => renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) + }) + + test("caps lowercase sentences separated by newlines", () => { + expect(() => renderAnswer({ basis: "one.\ntwo.\nthree.", fields: [{ name: "", value: 2 }] })).toThrow( + /basis.*2 sentences/i, + ) + }) + + test.each(["2 orders", '"two orders"'])("counts sentence boundaries before %s", (next) => { + const basis = `First. ${next}. Third.` + expect(() => renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) }) test.each([".", "?", "!"])("caps sentences separated by %s and uppercase text", (punctuation) => { @@ -263,7 +276,7 @@ const context = { messages: [], } -test("answer_commit returns exact reply instructions and advisory metadata", async () => { +test("answer_commit returns only the rendered answer and advisory metadata", async () => { const tool = await initTool(AnswerCommitTool) const readings = [{ clause: "month", reading: "calendar month", result_summary: "2", decided_by: "request" }] const asks: unknown[] = [] @@ -276,7 +289,7 @@ test("answer_commit returns exact reply instructions and advisory metadata", asy }, }, ) - expect(result.output).toBe("**Answer:** 2\n\nReply with exactly the text above and nothing else.") + expect(result.output).toBe("**Answer:** 2") expect(result.metadata.readings).toEqual(readings) expect(asks).toEqual([{ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }]) }) @@ -294,11 +307,11 @@ test("answer_commit exposes a serializable object input schema", async () => { ]) }) -test("answer_commit description defines final-call timing and verbatim reply", async () => { +test("answer_commit description defines final-call timing and exact reply", async () => { const tool = await initTool(AnswerCommitTool) expect(tool.description).toContain("LAST tool call") expect(tool.description).toContain("once the answer is decided") - expect(tool.description).toContain("verbatim as your final reply") + expect(tool.description).toContain("reply with exactly the returned text and nothing else") }) test("answer_commit description defines answer_only and unused properties", async () => { @@ -325,7 +338,7 @@ test("answer_commit accepts null optional fields through the tool wrapper", asyn }, context, ) - expect(result.output).toBe("**Answer:** 1.25\n\nReply with exactly the text above and nothing else.") + expect(result.output).toBe("**Answer:** 1.25") expect(result.metadata.readings).toEqual([]) }) From 991dc56e0a029cf2269393220a370f232b2521e5 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 14:11:04 -0700 Subject: [PATCH 04/39] fix(analyst): `answer_commit` rejects unsafe integers, echoes at the step limit, uses native Effect Review-bot findings of the highest severity tier plus their siblings: - Numeric field values and table cells above `Number.MAX_SAFE_INTEGER` are rejected (JSON parsing would have rounded them); exact strings are accepted instead. - At the final allotted generation step, an analyst turn whose previous step completed `answer_commit` is asked to reply with exactly the returned text, with tools disabled and a user output schema still taking precedence, instead of being asked for a summary that would discard the committed answer. - The tool exposes the namespace projection the registry consumes, runs as a native Effect definition with native permission handling, and its tests use scoped Effect fixtures. Co-Authored-By: Claude Fable 5.1 --- .../src/altimate/tools/answer-commit.ts | 60 +++++-- packages/opencode/src/session/prompt.ts | 27 ++- packages/opencode/src/tool/registry.ts | 4 +- .../opencode/test/agent/answer-commit.test.ts | 154 +++++++++--------- .../test/altimate/tools/answer-commit.test.ts | 115 ++++++++++--- packages/opencode/test/session/prompt.test.ts | 103 ++++++++++++ 6 files changed, 339 insertions(+), 124 deletions(-) diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 24538ec37c..eb2d4f1d64 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -1,4 +1,6 @@ import z from "zod" +import { Effect, Option, Schema, SchemaIssue } from "effect" +import type { JSONSchema7 } from "@ai-sdk/provider" import { Tool } from "../../tool/tool" // Strict-schema callers fill unused optional properties with null, "", [] or {}. @@ -15,6 +17,13 @@ function optional(schema: T) { }) } +const number = z + .number() + .refine( + (value) => !Number.isInteger(value) || Number.isSafeInteger(value), + "Numeric integers must be safe integers; supply larger values as a string", + ) + const field = z .object({ name: z @@ -22,7 +31,7 @@ const field = z .refine((value) => value === "" || value.trim().length > 0, "Field name must be non-empty") .describe("Field label; use an empty name for an unnamed value"), value: z - .union([z.string(), z.number()], { error: "Each field value must be one non-empty string or number" }) + .union([z.string(), number], { error: "Each field value must be one non-empty string or number" }) .refine((value) => typeof value !== "string" || value.trim().length > 0, "Field value must be non-empty"), units: optional(z.string()), precision: optional(z.number().int().min(0).max(10)).describe( @@ -34,9 +43,7 @@ const field = z const rows = z .object({ columns: z.array(z.string()).min(1, "rows.columns requires at least one column"), - values: z - .array(z.array(z.union([z.string(), z.number(), z.null()]))) - .min(1, "rows.values requires at least one row"), + values: z.array(z.array(z.union([z.string(), number, z.null()]))).min(1, "rows.values requires at least one row"), note: optional(z.string()), }) .strict() @@ -145,18 +152,35 @@ export function renderAnswer(payload: unknown): string { return `${basis}**Answer:** unable to determine — ${args.unresolved!.reason}` } -export const AnswerCommitTool = Tool.define("answer_commit", { - description: - "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", - parameters, - formatValidationError: validationError, - async execute(args, ctx) { - if (ctx.agent !== "analyst") throw new Error("answer_commit is only available to the analyst agent") - await ctx.ask({ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }) - return { - title: "Answer committed", - metadata: { readings: args.readings ?? [], truncated: false }, - output: renderAnswer(args), - } - }, +// Keep the renderer and native Effect tool on the same parser, including filler normalization. +const Parameters = Schema.declareConstructor, AnswerCommitInput>()([], () => (input) => { + const parsed = parameters.safeParse(input) + return parsed.success + ? Effect.succeed(parsed.data) + : Effect.fail(new SchemaIssue.InvalidValue(Option.some(input), { message: validationError(parsed.error) })) }) + +export const AnswerCommitTool = Tool.define( + "answer_commit", + Effect.succeed({ + description: + "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", + parameters: Parameters, + // Preserve the existing wire schema; Zod and the SDK use different recursive JSON Schema types. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + jsonSchema: z.toJSONSchema(parameters, { io: "input" }) as JSONSchema7, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (ctx.agent !== "analyst") + return yield* Effect.die(new Error("answer_commit is only available to the analyst agent")) + yield* ctx.ask({ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }) + return { + title: "Answer committed", + metadata: { readings: args.readings ?? [], truncated: false }, + output: renderAnswer(args), + } + }), + }), +) + +export * as AnswerCommit from "./answer-commit" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 043a5f8b6d..f2f673e9d2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1676,6 +1676,19 @@ export namespace SessionPrompt { if (format.type === "json_schema") { system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) } + // altimate_change start - the final step echoes a successful commit instead of replacing it with a summary + const lastTool = msgs + .find((msg) => msg.info.id === lastAssistant?.id) + ?.parts.findLast((part) => part.type === "tool") + const echoCommittedAnswer = + isLastStep && + format.type === "text" && + agent.name === "analyst" && + lastAssistant?.parentID === lastUser.id && + lastAssistant.agent === "analyst" && + lastTool?.tool === "answer_commit" && + lastTool.state.status === "completed" + // altimate_change end // altimate_change start - trace system prompt once per loop() call. // The system prompt is functionally identical across steps within a single @@ -1726,14 +1739,22 @@ export namespace SessionPrompt { ? [ { role: "assistant" as const, - content: MAX_STEPS, + // altimate_change start - preserve the committed answer within the existing final generation + content: echoCommittedAnswer + ? "Tools are disabled. Reply with exactly the text returned by answer_commit and nothing else." + : MAX_STEPS, + // altimate_change end }, ] : []), ], - tools, + // altimate_change start - the final echo cannot spend another step on tools + tools: echoCommittedAnswer ? {} : tools, + // altimate_change end model, - toolChoice: format.type === "json_schema" ? "required" : undefined, + // altimate_change start - a user output schema still takes precedence over text finalization + toolChoice: echoCommittedAnswer ? "none" : format.type === "json_schema" ? "required" : undefined, + // altimate_change end }) // If structured output was captured, save it and exit immediately diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f6fb58a5d9..e3c606b1ec 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -58,7 +58,7 @@ import { pathToFileURL } from "url" import { SqlExecuteTool } from "../altimate/tools/sql-execute" import { SqlMaterializeTool } from "../altimate/tools/sql-materialize" import { SqlCheckTool } from "../altimate/tools/sql-check" -import { AnswerCommitTool } from "../altimate/tools/answer-commit" +import { AnswerCommit } from "../altimate/tools/answer-commit" import { SchemaInspectTool } from "../altimate/tools/schema-inspect" import { SqlAnalyzeTool } from "../altimate/tools/sql-analyze" import { SqlOptimizeTool } from "../altimate/tools/sql-optimize" @@ -548,7 +548,7 @@ export namespace ToolRegistry { // resolvers' tools[item.id] assignment gives the native tool precedence over custom/plugin collisions. // MCP tools resolve afterward and can still replace this ID (e.g. server answer, tool commit). if (agent?.name === "analyst" && process.env.ALTIMATE_ANSWER_COMMIT !== "0") { - tools.push(await AppRuntime.runPromise(AnswerCommitTool)) + tools.push(await AppRuntime.runPromise(AnswerCommit.AnswerCommitTool)) } // altimate_change end const result = await Promise.all( diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 0f53abad8f..7485ef9381 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -6,7 +6,6 @@ import { ToolLookupTool } from "../../src/altimate/tools/tool-lookup" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" -import { Instance } from "../../src/project/instance" import type { Provider } from "../../src/provider/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { MessageID, SessionID } from "../../src/session/schema" @@ -14,12 +13,12 @@ import { SessionTools } from "../../src/session/tools" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { initTool } from "../altimate/tool-fixture" -import { disposeAllInstances, TestInstance, tmpdir } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { withLegacyInstance } from "../session/legacy-instance" const model = { providerID: ProviderID.opencode, modelID: ModelID.make("test") } -const it = testEffect(Layer.empty) +const it = testEffect(Layer.mergeAll(Agent.defaultLayer, ToolRegistry.layer)) let previousToggle: string | undefined beforeEach(() => { @@ -27,28 +26,44 @@ beforeEach(() => { delete process.env.ALTIMATE_ANSWER_COMMIT }) -afterEach(async () => { +afterEach(() => { if (previousToggle === undefined) delete process.env.ALTIMATE_ANSWER_COMMIT else process.env.ALTIMATE_ANSWER_COMMIT = previousToggle - await disposeAllInstances() }) describe("answer_commit agent scope", () => { - test("analyst permits and receives answer_commit", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const analyst = (await Agent.get("analyst"))! - expect(Permission.evaluate("answer_commit", "*", analyst.permission).action).toBe("allow") - const tools = await ToolRegistry.tools(model, analyst) - expect(tools.filter((tool) => tool.id === "answer_commit")).toHaveLength(1) - }, - }) + test("single-instance answer_commit cases use scoped Effect fixtures", async () => { + const source = await Bun.file(import.meta.path).text() + expect(source).not.toMatch(/\bInstance\.provide\s*\(/) + expect(source).not.toMatch(/\bawait\s+tmpdir\s*\(/) }) - test("a custom agent named analyst receives answer_commit by design", async () => { - await using tmp = await tmpdir({ + it.instance("analyst permits and receives answer_commit", () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + const analyst = yield* agents.get("analyst") + expect(Permission.evaluate("answer_commit", "*", analyst.permission).action).toBe("allow") + const tools = yield* registry.tools({ ...model, agent: analyst }) + expect(tools.filter((tool) => tool.id === "answer_commit")).toHaveLength(1) + }), + ) + + it.instance( + "a custom agent named analyst receives answer_commit by design", + () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + const analyst = yield* agents.get("analyst") + expect(analyst.description).toBe("Configured analyst") + expect(analyst.prompt).toBe("Use the configured analyst instructions.") + // Configuration customizes the native analyst; access intentionally follows its name. + expect(Permission.evaluate("answer_commit", "*", analyst.permission).action).toBe("allow") + const tools = yield* registry.tools({ ...model, agent: analyst }) + expect(tools.filter((tool) => tool.id === "answer_commit")).toHaveLength(1) + }), + { config: { agent: { analyst: { @@ -57,26 +72,15 @@ describe("answer_commit agent scope", () => { }, }, }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const analyst = (await Agent.get("analyst"))! - expect(analyst.description).toBe("Configured analyst") - expect(analyst.prompt).toBe("Use the configured analyst instructions.") - // Configuration customizes the native analyst; access intentionally follows its name. - expect(Permission.evaluate("answer_commit", "*", analyst.permission).action).toBe("allow") - const tools = await ToolRegistry.tools(model, analyst) - expect(tools.filter((tool) => tool.id === "answer_commit")).toHaveLength(1) - }, - }) - }) + }, + ) it.instance("session resolver selects native answer_commit over a plugin tool with the same id", () => withLegacyInstance( Effect.gen(function* () { const tmp = yield* TestInstance - const analyst = (yield* Effect.promise(() => Agent.get("analyst")))! + const agents = yield* Agent.Service + const analyst = yield* agents.get("analyst") let pluginCalls = 0 const plugin = Layer.mock(Plugin.Service, { list: () => @@ -181,8 +185,21 @@ describe("answer_commit agent scope", () => { ), ) - test("builder, plan, data-explorer and custom agents never receive answer_commit", async () => { - await using tmp = await tmpdir({ + it.instance( + "builder, plan, data-explorer and custom agents never receive answer_commit", + () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + for (const name of ["builder", "build", "plan", "data-explorer", "custom"]) { + const agent = yield* agents.get(name) + expect(agent).toBeDefined() + const tools = yield* registry.tools({ ...model, agent }) + expect(tools.map((tool) => tool.id)).not.toContain("answer_commit") + } + expect((yield* registry.tools(model)).map((tool) => tool.id)).not.toContain("answer_commit") + }), + { config: { permission: { "*": "allow", answer_commit: "allow" }, agent: { @@ -193,27 +210,14 @@ describe("answer_commit agent scope", () => { }, }, }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - for (const name of ["builder", "build", "plan", "data-explorer", "custom"]) { - const agent = await Agent.get(name) - expect(agent).toBeDefined() - const tools = await ToolRegistry.tools(model, agent) - expect(tools.map((tool) => tool.id)).not.toContain("answer_commit") - } - expect((await ToolRegistry.tools(model)).map((tool) => tool.id)).not.toContain("answer_commit") - }, - }) - }) + }, + ) - test("tool_lookup does not expose answer_commit to builder", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const tool = await initTool(ToolLookupTool) + it.instance("tool_lookup does not expose answer_commit to builder", () => + withLegacyInstance( + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const tool = yield* Effect.promise(() => initTool(ToolLookupTool)) const ctx = { sessionID: SessionID.make("ses_answer_commit"), messageID: MessageID.make("msg_answer_commit"), @@ -224,27 +228,27 @@ describe("answer_commit agent scope", () => { metadata: () => {}, ask: async () => {}, } - const missing = await tool.execute({ tool_name: "answer_commit" }, ctx) + const missing = yield* Effect.promise(() => tool.execute({ tool_name: "answer_commit" }, ctx)) expect(missing.title).toBe("Tool not found") - const listing = await tool.execute({ tool_name: "__missing_tool" }, ctx) + const listing = yield* Effect.promise(() => tool.execute({ tool_name: "__missing_tool" }, ctx)) expect(listing.output).not.toContain("answer_commit") - expect((await ToolRegistry.allInfos()).map((info) => info.id)).not.toContain("answer_commit") - expect(await ToolRegistry.ids()).not.toContain("answer_commit") - }, - }) - }) + expect((yield* registry.allInfos()).map((info) => info.id)).not.toContain("answer_commit") + expect(yield* registry.ids()).not.toContain("answer_commit") + }), + ), + ) - test("ALTIMATE_ANSWER_COMMIT=0 removes the analyst candidate and can be toggled back on", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const analyst = (await Agent.get("analyst"))! - process.env.ALTIMATE_ANSWER_COMMIT = "0" - expect((await ToolRegistry.tools(model, analyst)).map((tool) => tool.id)).not.toContain("answer_commit") - process.env.ALTIMATE_ANSWER_COMMIT = "1" - expect((await ToolRegistry.tools(model, analyst)).map((tool) => tool.id)).toContain("answer_commit") - }, - }) - }) + it.instance("ALTIMATE_ANSWER_COMMIT=0 removes the analyst candidate and can be toggled back on", () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + const analyst = yield* agents.get("analyst") + process.env.ALTIMATE_ANSWER_COMMIT = "0" + expect((yield* registry.tools({ ...model, agent: analyst })).map((tool) => tool.id)).not.toContain( + "answer_commit", + ) + process.env.ALTIMATE_ANSWER_COMMIT = "1" + expect((yield* registry.tools({ ...model, agent: analyst })).map((tool) => tool.id)).toContain("answer_commit") + }), + ) }) diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index eddab91b9e..0a9b5324ec 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -1,6 +1,64 @@ import { describe, expect, test } from "bun:test" -import { AnswerCommitTool, renderAnswer } from "../../../src/altimate/tools/answer-commit" -import { initTool } from "../tool-fixture" +import { rejects } from "node:assert/strict" +import { Effect } from "effect" +import { AnswerCommit } from "../../../src/altimate/tools/answer-commit" +import * as answerCommitModule from "../../../src/altimate/tools/answer-commit" +import { MessageID, SessionID } from "../../../src/session/schema" +import { initTool, toolInfo } from "../tool-fixture" + +test("answer_commit exposes the namespace projection", () => { + expect(answerCommitModule).toHaveProperty("AnswerCommit.AnswerCommitTool", AnswerCommit.AnswerCommitTool) + expect(answerCommitModule).toHaveProperty("AnswerCommit.renderAnswer", AnswerCommit.renderAnswer) +}) + +test("answer_commit executes synchronously with native Effect permissions", async () => { + const info = await toolInfo(AnswerCommit.AnswerCommitTool) + const tool = await Effect.runPromise(info.init()) + const asks: unknown[] = [] + const result = Effect.runSync( + tool.execute( + { fields: [{ name: "Count", value: 2 }] }, + { + ...context, + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + metadata: () => Effect.void, + ask: (input) => Effect.sync(() => void asks.push(input)), + }, + ), + ) + expect(result.output).toBe("**Answer:** Count: 2") + expect(asks).toEqual([{ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }]) +}) + +describe("answer_commit numeric safety", () => { + test.each(["9007199254740993", "-9007199254740993", "1e30"])("rejects unsafe field number %s", async (value) => { + const payload = JSON.parse(`{"fields":[{"name":"Count","value":${value}}]}`) + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(/fields.*value.*safe.*string/i) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + await rejects(tool.execute(payload, context), /fields.*value.*safe.*string/i) + }) + + test.each(["9007199254740993", "-9007199254740993", "1e30"])("rejects unsafe table number %s", async (value) => { + const payload = JSON.parse(`{"rows":{"columns":["Count"],"values":[[${value}]]}}`) + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(/rows.*values.*safe.*string/i) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + await rejects(tool.execute(payload, context), /rows.*values.*safe.*string/i) + }) + + test.each([Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER, 1.25, "9007199254740993", "-9007199254740993"])( + "preserves safe numbers and numeric strings: %s", + async (value) => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + const fields = { fields: [{ name: "", value }] } + const rows = { rows: { columns: ["Count"], values: [[value]] } } + expect(AnswerCommit.renderAnswer(fields)).toBe(`**Answer:** ${value}`) + expect((await tool.execute(fields, context)).output).toBe(`**Answer:** ${value}`) + expect(AnswerCommit.renderAnswer(rows)).toBe(`**Answer:**\n| Count |\n|---|\n| ${value} |`) + expect((await tool.execute(rows, context)).output).toBe(`**Answer:**\n| Count |\n|---|\n| ${value} |`) + }, + ) +}) describe("renderAnswer golden text", () => { const cases: [string, unknown, string][] = [ @@ -157,23 +215,25 @@ describe("renderAnswer golden text", () => { ] test.each(cases)("%s", async (_name, payload, expected) => { - expect(renderAnswer(payload)).toBe(expected) - const tool = await initTool(AnswerCommitTool) + expect(AnswerCommit.renderAnswer(payload)).toBe(expected) + const tool = await initTool(AnswerCommit.AnswerCommitTool) expect((await tool.execute(payload, context)).output).toBe(expected) }) }) describe("renderAnswer strict-schema null fillers", () => { test.each(["precision", "units"])("null %s is absent", (key) => { - expect(renderAnswer({ fields: [{ name: "", value: 1.25, [key]: null }] })).toBe("**Answer:** 1.25") + expect(AnswerCommit.renderAnswer({ fields: [{ name: "", value: 1.25, [key]: null }] })).toBe("**Answer:** 1.25") }) test.each(["basis", "readings", "answer_only"])("null %s is absent", (key) => { - expect(renderAnswer({ fields: [{ name: "Count", value: 2 }], [key]: null })).toBe("**Answer:** Count: 2") + expect(AnswerCommit.renderAnswer({ fields: [{ name: "Count", value: 2 }], [key]: null })).toBe( + "**Answer:** Count: 2", + ) }) test("null note is absent", () => { - expect(renderAnswer({ rows: { columns: ["Count"], values: [[2]], note: null } })).toBe( + expect(AnswerCommit.renderAnswer({ rows: { columns: ["Count"], values: [[2]], note: null } })).toBe( "**Answer:**\n| Count |\n|---|\n| 2 |", ) }) @@ -182,45 +242,48 @@ describe("renderAnswer strict-schema null fillers", () => { describe("renderAnswer basis sentence boundaries", () => { test.each(["e.g.", "i.e.", "etc.", "Dr.", "vs.", "approx."])("does not count %s as a sentence end", (word) => { const basis = `Used ${word} Completed orders. Count is 2.` - expect(renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) + expect(AnswerCommit.renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) }) test("does not count decimals as sentence ends", () => { const basis = "The mean is 3.5 and the median is 2.5. Count is 2." - expect(renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) + expect(AnswerCommit.renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) }) test("punctuation followed by lowercase text counts as a sentence boundary", () => { const basis = "Count? maybe! still checking. Count is 2." - expect(() => renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) + expect(() => AnswerCommit.renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) }) test("caps lowercase sentences separated by newlines", () => { - expect(() => renderAnswer({ basis: "one.\ntwo.\nthree.", fields: [{ name: "", value: 2 }] })).toThrow( + expect(() => AnswerCommit.renderAnswer({ basis: "one.\ntwo.\nthree.", fields: [{ name: "", value: 2 }] })).toThrow( /basis.*2 sentences/i, ) }) test.each(["2 orders", '"two orders"'])("counts sentence boundaries before %s", (next) => { const basis = `First. ${next}. Third.` - expect(() => renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) + expect(() => AnswerCommit.renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) }) test.each([".", "?", "!"])("caps sentences separated by %s and uppercase text", (punctuation) => { const basis = `First${punctuation} Second${punctuation} Third${punctuation}` - expect(() => renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) + expect(() => AnswerCommit.renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) }) test("abbreviations and decimals do not hide a third sentence", () => { expect(() => - renderAnswer({ basis: "Used e.g. Orders. Mean is 3.5. Count is 2.", fields: [{ name: "", value: 2 }] }), + AnswerCommit.renderAnswer({ + basis: "Used e.g. Orders. Mean is 3.5. Count is 2.", + fields: [{ name: "", value: 2 }], + }), ).toThrow(/basis.*2 sentences/i) }) test("paragraph breaks count as sentence boundaries", () => { - expect(() => renderAnswer({ basis: "One.\n\nTwo.\n\nThree.", fields: [{ name: "", value: 2 }] })).toThrow( - /basis.*2 sentences/i, - ) + expect(() => + AnswerCommit.renderAnswer({ basis: "One.\n\nTwo.\n\nThree.", fields: [{ name: "", value: 2 }] }), + ).toThrow(/basis.*2 sentences/i) }) }) @@ -264,7 +327,7 @@ describe("renderAnswer structural validation", () => { /exactly one of fields, rows, or unresolved/i, ], ] as [string, unknown, RegExp][])("rejects %s", (_name, payload, error) => { - expect(() => renderAnswer(payload)).toThrow(error) + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(error) }) }) @@ -277,7 +340,7 @@ const context = { } test("answer_commit returns only the rendered answer and advisory metadata", async () => { - const tool = await initTool(AnswerCommitTool) + const tool = await initTool(AnswerCommit.AnswerCommitTool) const readings = [{ clause: "month", reading: "calendar month", result_summary: "2", decided_by: "request" }] const asks: unknown[] = [] const result = await tool.execute( @@ -295,7 +358,7 @@ test("answer_commit returns only the rendered answer and advisory metadata", asy }) test("answer_commit exposes a serializable object input schema", async () => { - const tool = await initTool(AnswerCommitTool) + const tool = await initTool(AnswerCommit.AnswerCommitTool) expect(tool.jsonSchema?.type).toBe("object") expect(Object.keys(tool.jsonSchema?.properties ?? {})).toEqual([ "fields", @@ -308,27 +371,27 @@ test("answer_commit exposes a serializable object input schema", async () => { }) test("answer_commit description defines final-call timing and exact reply", async () => { - const tool = await initTool(AnswerCommitTool) + const tool = await initTool(AnswerCommit.AnswerCommitTool) expect(tool.description).toContain("LAST tool call") expect(tool.description).toContain("once the answer is decided") expect(tool.description).toContain("reply with exactly the returned text and nothing else") }) test("answer_commit description defines answer_only and unused properties", async () => { - const tool = await initTool(AnswerCommitTool) + const tool = await initTool(AnswerCommit.AnswerCommitTool) expect(tool.description).toContain("only the answer or a specific format") expect(tool.description).toContain("exactly the value(s) or table, with no basis, field names, note, or answer label") expect(tool.description).toContain("Omit unused optional properties") }) test("answer_commit description gives the user output schema precedence", async () => { - const tool = await initTool(AnswerCommitTool) + const tool = await initTool(AnswerCommit.AnswerCommitTool) expect(tool.description).toContain("not automatically removed when a user output schema is set") expect(tool.description).toContain("the user schema takes precedence") }) test("answer_commit accepts null optional fields through the tool wrapper", async () => { - const tool = await initTool(AnswerCommitTool) + const tool = await initTool(AnswerCommit.AnswerCommitTool) const result = await tool.execute( { fields: [{ name: "", value: 1.25, precision: null, units: null }], @@ -343,14 +406,14 @@ test("answer_commit accepts null optional fields through the tool wrapper", asyn }) test("answer_commit names invalid field values through the tool wrapper", async () => { - const tool = await initTool(AnswerCommitTool) + const tool = await initTool(AnswerCommit.AnswerCommitTool) await expect(tool.execute({ fields: [{ name: "Count", value: "" }] }, context)).rejects.toThrow( /fields.*value.*non-empty/i, ) }) test("answer_commit refuses direct execution by another agent", async () => { - const tool = await initTool(AnswerCommitTool) + const tool = await initTool(AnswerCommit.AnswerCommitTool) await expect(tool.execute({ fields: [{ name: "", value: 2 }] }, { ...context, agent: "builder" })).rejects.toThrow( /only.*analyst/i, ) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 971a0138eb..42a19fdd14 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -980,6 +980,109 @@ it.instance("learn file hooks append glob and anchor matches once and respect th // Loop semantics +// altimate_change start - an answer committed before the step limit must still be echoed +for (const mode of ["committed", "failed", "structured"] as const) { + noLLMServer.instance( + `answer_commit at the step limit preserves ${mode === "committed" ? "the exact final echo" : mode === "failed" ? "the failed-call limit" : "the user output schema"}`, + () => + Effect.gen(function* () { + const { prompt, chat, sessions } = yield* boot() + const requests: Record[] = [] + const answer = "42" + const args = mode === "failed" ? {} : { fields: [{ name: "", value: 42 }], answer_only: true } + const language = createOpenAICompatible({ + name: "test", + baseURL: "https://unused.invalid/v1", + fetch: Object.assign( + async (_url: string | URL | Request, init?: RequestInit) => { + if (typeof init?.body !== "string") throw new Error("expected a JSON request body") + const request = JSON.parse(init.body) + requests.push(request) + if (requests.length > 2) throw new Error("answer_commit exceeded the two-step budget") + const limited = JSON.stringify(request.messages).includes("CRITICAL - MAXIMUM STEPS REACHED") + const response = + requests.length === 1 + ? reply().tool("answer_commit", args).item() + : mode === "structured" + ? reply().tool("StructuredOutput", { count: 42 }).item() + : reply() + .text(limited ? "Maximum steps reached." : answer) + .stop() + .item() + if (response.type !== "sse") throw new Error("expected SSE response") + return new Response( + [...response.head, ...response.tail].map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + + "data: [DONE]\n\n", + { headers: { "Content-Type": "text/event-stream" } }, + ) + }, + { preconnect: () => undefined }, + ), + }).chatModel("test-model") + const languageSpy = spyOn(ProviderSvc, "getLanguage").mockResolvedValue(language) + try { + const result = yield* prompt.prompt({ + sessionID: chat.id, + agent: "analyst", + model: promptRef, + parts: [{ type: "text", text: "How many rows? Reply with only the count." }], + ...(mode === "structured" + ? { + format: { + type: "json_schema" as const, + retryCount: 0, + schema: { + type: "object", + properties: { count: { type: "number" } }, + required: ["count"], + additionalProperties: false, + }, + }, + } + : {}), + }) + expect(result.info).not.toHaveProperty("error") + expect(requests).toHaveLength(2) + const messages = yield* sessions.messages({ sessionID: chat.id }) + const commit = messages + .flatMap((message) => message.parts) + .find((part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "answer_commit") + expect(commit?.state.status).toBe(mode === "failed" ? "error" : "completed") + if (mode === "committed") { + expect(JSON.stringify(requests[1].messages)).not.toContain("CRITICAL - MAXIMUM STEPS REACHED") + expect(requests[1].tools ?? []).toHaveLength(0) + expect( + result.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""), + ).toBe(answer) + } else if (mode === "failed") { + expect(JSON.stringify(requests[1].messages)).toContain("CRITICAL - MAXIMUM STEPS REACHED") + expect(result.parts).toContainEqual( + expect.objectContaining({ type: "text", text: "Maximum steps reached." }), + ) + } else { + expect(requests[1].tool_choice).toBe("required") + expect(result.info).toHaveProperty("structured", { count: 42 }) + } + } finally { + languageSpy.mockRestore() + } + }), + { + config: { + ...cfg, + snapshot: false, + learn: { enabled: false }, + agent: { analyst: { steps: 2, permission: { StructuredOutput: "allow" } } }, + }, + }, + 30_000, + ) +} +// altimate_change end + noLLMServer.instance( "loop exits immediately when last assistant has stop finish", () => From c17d4cb9d1502c1d9c81bb0073cd9d6de19f61f7 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 14:23:40 -0700 Subject: [PATCH 05/39] fix(tools): valid draft 2020-12 schema for `answer_commit` fillers; omit root `$schema` for Anthropic SDKs A live probe against Bedrock Claude rejected the analyst tool list: `tools.45.custom.input_schema: JSON schema is invalid. It must match JSON Schema draft 2020-12`. The `answer_commit` optional-filler helper used `z.tuple([])`, which Zod emits as `"prefixItems": []`, invalid under 2020-12 (`prefixItems` needs at least one item). OpenAI accepted the same schema, so only Anthropic-family providers were affected. - Replace the empty tuple with `z.array(z.never()).length(0)`, which still accepts exactly an empty array and keeps union error selection. - Omit the root `$schema` declaration in the provider transform for the Anthropic, Bedrock and Vertex Anthropic SDKs. - Add `tool-schema-anthropic.test.ts`: every registered tool schema for the analyst and builder agents is validated against the draft 2020-12 meta-schema (ajv, dev dependency) plus Anthropic's restrictions, so an invalid tool schema fails in CI instead of at the provider. Co-Authored-By: Claude Fable 5.1 --- bun.lock | 1 + packages/opencode/package.json | 1 + .../src/altimate/tools/answer-commit.ts | 2 +- packages/opencode/src/provider/transform.ts | 7 + .../test/altimate/tools/answer-commit.test.ts | 21 ++ .../tools/tool-schema-anthropic.test.ts | 235 ++++++++++++++++++ 6 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/altimate/tools/tool-schema-anthropic.test.ts diff --git a/bun.lock b/bun.lock index 46b47cd422..a48808397e 100644 --- a/bun.lock +++ b/bun.lock @@ -371,6 +371,7 @@ "@types/turndown": "5.0.5", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", + "ajv": "8.20.0", "drizzle-orm": "catalog:", "playwright-core": "1.59.1", "prettier": "3.6.2", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 722248cde9..764cae76e0 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -48,6 +48,7 @@ "@types/turndown": "5.0.5", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", + "ajv": "8.20.0", "drizzle-orm": "catalog:", "playwright-core": "1.59.1", "prettier": "3.6.2", diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index eb2d4f1d64..ab6cef9477 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -7,7 +7,7 @@ import { Tool } from "../../tool/tool" // Only optional properties accept these fillers; required values still validate. function optional(schema: T) { return z - .union([schema, z.null(), z.literal(""), z.tuple([]), z.object({}).strict()]) + .union([schema, z.null(), z.literal(""), z.array(z.never()).length(0), z.object({}).strict()]) .optional() .transform((value) => { if (value === null || value === undefined || (typeof value === "string" && !value.trim())) return undefined diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cdb200eff6..d88188639c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1525,6 +1525,13 @@ export namespace ProviderTransform { // altimate_change end export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 { + // altimate_change start — omit the tool schema dialect declaration for Anthropic, Bedrock, and Vertex + if (["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) { + const { $schema, ...rest } = schema + schema = rest + } + // altimate_change end + /* if (["openai", "azure"].includes(providerID)) { if (schema.type === "object" && schema.properties) { diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index 0a9b5324ec..5a352ceef8 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test" +import Ajv2020 from "ajv/dist/2020" import { rejects } from "node:assert/strict" import { Effect } from "effect" import { AnswerCommit } from "../../../src/altimate/tools/answer-commit" import * as answerCommitModule from "../../../src/altimate/tools/answer-commit" import { MessageID, SessionID } from "../../../src/session/schema" +import { ToolJsonSchema } from "../../../src/tool/json-schema" import { initTool, toolInfo } from "../tool-fixture" test("answer_commit exposes the namespace projection", () => { @@ -288,6 +290,25 @@ describe("renderAnswer basis sentence boundaries", () => { }) describe("renderAnswer structural validation", () => { + test("optional array fillers accept only empty arrays", async () => { + expect(AnswerCommit.renderAnswer({ fields: [{ name: "", value: 2, precision: [], units: [] }], basis: [] })).toBe( + "**Answer:** 2", + ) + for (const filler of [[null], [0], [""], [{}], [[]]]) { + expect(() => AnswerCommit.renderAnswer({ fields: [{ name: "", value: 2, precision: filler }] })).toThrow( + /precision/i, + ) + expect(() => AnswerCommit.renderAnswer({ fields: [{ name: "", value: 2, units: filler }] })).toThrow(/units/i) + expect(() => AnswerCommit.renderAnswer({ fields: [{ name: "", value: 2 }], basis: filler })).toThrow(/basis/i) + } + + // Empty tuples parse the same fillers but emit invalid draft 2020-12 prefixItems. + const info = await toolInfo(AnswerCommit.AnswerCommitTool) + const tool = await Effect.runPromise(info.init()) + const ajv = new Ajv2020({ strict: false, validateFormats: false }) + expect(ajv.validateSchema(ToolJsonSchema.fromTool({ id: info.id, ...tool }))).toBe(true) + }) + test.each([ ["empty value", { fields: [{ name: "Count", value: "" }] }, /fields.*value.*non-empty/i], ["blank value", { fields: [{ name: "Count", value: " " }] }, /fields.*value.*non-empty/i], diff --git a/packages/opencode/test/altimate/tools/tool-schema-anthropic.test.ts b/packages/opencode/test/altimate/tools/tool-schema-anthropic.test.ts new file mode 100644 index 0000000000..0c8a9c25cc --- /dev/null +++ b/packages/opencode/test/altimate/tools/tool-schema-anthropic.test.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, test } from "bun:test" +import Ajv2020 from "ajv/dist/2020" +import path from "node:path" +import { Effect } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Agent } from "@/agent/agent" +import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { PermissionNext } from "@/permission/next" +import type { Provider } from "@/provider/provider" +import { ModelID, ProviderID } from "@/provider/schema" +import { ProviderTransform } from "@/provider/transform" +import { ToolJsonSchema } from "@/tool/json-schema" +import { ToolRegistry } from "@/tool/registry" +import { TestConfig } from "../../fixture/config" +import { disposeAllInstances } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const fixture = (enableParallel: boolean) => + testEffect( + LayerNode.buildLayer(LayerNode.group([ToolRegistry.node, Agent.node]), { + replacements: [ + LayerNode.replace(Config.node, TestConfig.layer()), + LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ enableParallel })), + ], + }), + ) + +afterEach(disposeAllInstances) + +const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false }) +const formats = new Set([ + "date-time", + "date", + "time", + "duration", + "email", + "idn-email", + "hostname", + "idn-hostname", + "ipv4", + "ipv6", + "uri", + "uri-reference", + "iri", + "iri-reference", + "uuid", + "uri-template", + "json-pointer", + "relative-json-pointer", + "regex", +]) + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function schemaErrors(schema: object | boolean): string[] { + const errors = ajv.validateSchema(schema) ? [] : [ajv.errorsText(ajv.errors, { separator: "\n" })] + function visit(value: unknown, location = "#") { + if (!isObject(value)) return + for (const keyword of ["$schema", "nullable"]) { + if (keyword in value) errors.push(`${location}/${keyword} is not supported by Anthropic`) + } + for (const keyword of ["exclusiveMinimum", "exclusiveMaximum"]) { + if (typeof value[keyword] === "boolean") errors.push(`${location}/${keyword} must be numeric`) + } + if (typeof value.format === "string" && !formats.has(value.format)) { + errors.push(`${location}/format is not a standard format: ${value.format}`) + } + if ( + "additionalProperties" in value && + typeof value.additionalProperties !== "boolean" && + !isObject(value.additionalProperties) + ) { + errors.push(`${location}/additionalProperties must be boolean or a schema`) + } + // Walk schema positions only: property names and enum/default values may contain these keywords. + for (const keyword of ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]) { + if (!isObject(value[keyword])) continue + for (const [name, child] of Object.entries(value[keyword])) visit(child, `${location}/${keyword}/${name}`) + } + for (const keyword of ["allOf", "anyOf", "oneOf", "prefixItems"]) { + if (!Array.isArray(value[keyword])) continue + value[keyword].forEach((child, index) => visit(child, `${location}/${keyword}/${index}`)) + } + for (const keyword of [ + "items", + "contains", + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "propertyNames", + "not", + "if", + "then", + "else", + "contentSchema", + ]) { + visit(value[keyword], `${location}/${keyword}`) + } + } + visit(schema) + return errors +} + +function model(providerID: ProviderID): Provider.Model { + const id = providerID === ProviderID.amazonBedrock ? "global.anthropic.claude-opus-5-5" : "claude-opus-5-5" + return { + id: ModelID.make(id), + providerID, + api: { id, npm: `@ai-sdk/${providerID}`, url: "https://example.invalid" }, + name: id, + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200_000, output: 32_000 }, + status: "active", + release_date: "2026-01-01", + options: {}, + headers: {}, + } +} + +describe("Anthropic tool JSON schemas", () => { + for (const enableParallel of [false, true]) { + const it = fixture(enableParallel) + for (const providerID of [ProviderID.anthropic, ProviderID.amazonBedrock]) { + for (const name of ["analyst", "builder"]) { + it.instance( + `${providerID}, search ${enableParallel ? "enabled" : "disabled"}: every registered ${name} tool satisfies draft 2020-12 and Anthropic restrictions`, + () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const agent = yield* agents.get(name) + if (!agent) throw new Error(`${name} agent not found`) + const target = model(providerID) + const tools = yield* registry.tools({ providerID, modelID: target.id, agent }) + expect(tools.map((tool) => tool.id)).toEqual(expect.arrayContaining(["sql_check", "sql_execute", "read"])) + expect(tools.some((tool) => tool.id === "answer_commit")).toBe(name === "analyst") + + // Match prompt.ts / session/tools.ts conversion. Both SDK providers forward this schema unchanged. + // Also retain the legacy LLM.stream permission-filtered insertion order for offline diagnostics. + const disabled = PermissionNext.disabled( + tools.map((tool) => tool.id), + agent.permission, + ) + let requestIndex = 0 + const schemas = tools.map((tool, registryIndex) => ({ + name: tool.id, + registryIndex, + requestIndex: tool.id === "invalid" || disabled.has(tool.id) ? null : requestIndex++, + schema: JSON.parse(JSON.stringify(ProviderTransform.schema(target, ToolJsonSchema.fromTool(tool)))), + })) + const dump = process.env.TOOL_SCHEMA_DUMP_DIR + if (dump) { + yield* Effect.promise(() => + Bun.write( + path.join(dump, `${providerID}-${name}${enableParallel ? "-search" : ""}.json`), + JSON.stringify(schemas, null, 2), + ), + ) + } + // Validate every registered tool, including ones hidden by this agent's permissions. + const errors = schemas.flatMap((tool) => + schemaErrors(tool.schema).map((error) => `${tool.name}: ${error}`), + ) + expect(errors).toEqual([]) + }), + ) + } + } + } + + test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])( + "%s removes only the root dialect declaration without mutating the input", + (npm) => { + const target = model(ProviderID.anthropic) + target.api.npm = npm + const expected = { + type: "object" as const, + properties: { + $schema: { type: "string" as const }, + value: { type: "number" as const, exclusiveMinimum: 0 }, + empty: { type: "array" as const, items: {}, minItems: 0, maxItems: 0 }, + }, + required: ["value"], + additionalProperties: false, + } + const input = { $schema: "https://json-schema.org/draft/2020-12/schema", ...expected } + const original = structuredClone(input) + expect(ProviderTransform.schema(target, input)).toEqual(expected) + expect(input).toEqual(original) + }, + ) + + test("rejects empty tuple prefixItems and nested Anthropic-incompatible keywords", () => { + for (const fragment of [ + { type: "array", prefixItems: [] }, + { $schema: "https://json-schema.org/draft/2020-12/schema" }, + { nullable: true }, + { type: "number", exclusiveMinimum: true }, + { type: "number", exclusiveMaximum: false }, + { type: "string", format: "custom-format" }, + { type: "object", additionalProperties: [] }, + ]) { + expect(schemaErrors({ type: "object", properties: { value: { anyOf: [fragment] } } }).length).toBeGreaterThan(0) + } + }) + + test("allows type arrays, compositions, standard formats, and keyword-like data", () => { + expect( + schemaErrors({ + type: "object", + properties: { + nullable: { type: ["string", "null"] }, + $schema: { type: "string", format: "uri" }, + value: { anyOf: [{ type: "number", exclusiveMinimum: 0 }, { type: "null" }] }, + choice: { oneOf: [{ const: 1 }, { const: 2 }] }, + payload: { enum: [{ nullable: true, $schema: "literal data" }] }, + labels: { type: "object", additionalProperties: { type: "string" } }, + }, + additionalProperties: false, + }), + ).toEqual([]) + }) +}) From 1e5b2cca1284f3e9aa4855a51296b935bdd0a593 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 14:44:39 -0700 Subject: [PATCH 06/39] fix(analyst): reserve the `answer_commit` id against external tools; keep the committed text exact through compaction Two review findings of the highest severity tier, each with red-first tests: - The native `answer_commit` id is reserved: an MCP, plugin or file tool with the same name (including Windows case-equivalent spellings, and with the toggle off) cannot take the analyst's finalizer permission. - The committed answer text survives automatic compaction and provider retries unchanged; continuation ids are tracked so a newer request is not overwritten by an older echo. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/mcp/index.ts | 7 ++ packages/opencode/src/session/compaction.ts | 9 ++ packages/opencode/src/session/prompt.ts | 75 ++++++++++-- packages/opencode/src/tool/registry.ts | 13 ++- .../opencode/test/agent/answer-commit.test.ts | 51 ++++++++ packages/opencode/test/mcp/lifecycle.test.ts | 51 ++++++++ packages/opencode/test/session/prompt.test.ts | 109 +++++++++++++++--- 7 files changed, 281 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 5efa2c0538..da8e75a1ce 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -47,6 +47,9 @@ import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { McpCatalog } from "./catalog" +// altimate_change start — reserved tool IDs must use the permission matcher's platform semantics. +import { Wildcard } from "@/util/wildcard" +// altimate_change end // altimate_change start — persist enabled flag + telemetry import { findAllConfigPaths, listMcpInConfig, addMcpToConfig, readMcpEntryFromDisk } from "./config" import { Global } from "@opencode-ai/core/global" @@ -1246,6 +1249,10 @@ export const layer = Layer.effect( const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) for (const mcpTool of listed) { const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name) + // altimate_change start — reserve the native finalizer's permission and echo identity, + // including when ALTIMATE_ANSWER_COMMIT=0 disables the native tool. + if (Wildcard.match(key, "answer_commit")) continue + // altimate_change end // attach the original client name for source classification downstream. result[key] = Object.assign(McpCatalog.convertTool(mcpTool, client, timeout), { client: clientName }) } diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 3388f8d034..1e2e3bde52 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1288,6 +1288,9 @@ export namespace SessionCompaction { abort: AbortSignal auto: boolean overflow?: boolean + // altimate_change start - let the prompt loop retain request-scoped answers across compaction + onContinuation?: (messageID: MessageID) => void + // altimate_change end // altimate_change start — keep nudge delivery scoped to the active prompt generation nudgeGeneration?: NudgeArbiter.Generation // altimate_change end @@ -1697,6 +1700,9 @@ When constructing the summary, try to stick to this template: }) } // altimate_change end + // altimate_change start - report the persisted replay's identity, independent of its part flags + input.onContinuation?.(replayMsg.id) + // altimate_change end } else { // altimate_change start — the continue message // carries the original format/tools/system/variant, exactly as the replay @@ -1783,6 +1789,9 @@ When constructing the summary, try to stick to this template: end: Date.now(), }, }) + // altimate_change start - report the persisted continuation's identity + input.onContinuation?.(continueMsg.id) + // altimate_change end } } if (processor.message.error) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f2f673e9d2..f755bf3006 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -816,6 +816,10 @@ export namespace SessionPrompt { }) using _unsubToolsChanged = defer(unsubscribeToolsChanged) // altimate_change end + // altimate_change start - retain the exact committed text through automatic compaction + let committedAnswer: { userID: MessageID; output: string; compacted?: true } | undefined + const compactionContinuations = new Set() + // altimate_change end while (true) { // altimate_change start — SessionStatus.set became async in v1.4.0; await so busy state flushes before LLM call await SessionStatus.set(sessionID, { type: "busy" }) @@ -863,6 +867,41 @@ export namespace SessionPrompt { } if (!lastUser) throw new Error("No user message found in stream. This should never happen.") + // altimate_change start - only compaction-created continuations belong to the same request + const commitRequest = msgs.findLast( + (msg) => msg.info.role === "user" && !msg.parts.some((part) => part.type === "compaction"), + ) + if ( + committedAnswer && + msgs.some( + (msg) => + msg.info.role === "user" && + msg.info.id > committedAnswer!.userID && + !msg.parts.some((part) => part.type === "compaction") && + !compactionContinuations.has(msg.info.id), + ) + ) + committedAnswer = undefined + if ( + commitRequest?.info.role === "user" && + lastAssistant?.parentID === commitRequest.info.id && + !lastAssistant.summary + ) { + const lastTool = msgs + .find((msg) => msg.info.id === lastAssistant.id) + ?.parts.findLast((part) => part.type === "tool") + // An echo that overflowed has no finish or tool part; keep its committed output for the retry. + if (lastTool || lastAssistant.finish) + committedAnswer = + commitRequest.info.agent === "analyst" && + (commitRequest.info.format?.type ?? "text") === "text" && + lastAssistant.agent === "analyst" && + lastTool?.tool === "answer_commit" && + lastTool.state.status === "completed" + ? { userID: commitRequest.info.id, output: lastTool.state.output } + : undefined + } + // altimate_change end // altimate_change start — client trace for this step. A user message carrying a compaction // part was created by this loop: it continues the turn that triggered it. Any other user // message is a turn of its own, traced or not. @@ -1139,6 +1178,11 @@ export namespace SessionPrompt { sessionID, auto: task.auto, overflow: task.overflow, + // altimate_change start - distinguish automatic continuations from user-authored synthetic parts + onContinuation: (messageID) => { + compactionContinuations.add(messageID) + }, + // altimate_change end // altimate_change start — keep nudge delivery scoped to this prompt generation nudgeGeneration, // altimate_change end @@ -1154,6 +1198,9 @@ export namespace SessionPrompt { // re-enters compaction on the same unresolved marker and busy-loops. if (result !== "continue") break // altimate_change end + // altimate_change start - compaction must not replace a committed answer with further analysis + if (task.auto && committedAnswer) committedAnswer.compacted = true + // altimate_change end continue } @@ -1677,17 +1724,23 @@ export namespace SessionPrompt { system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) } // altimate_change start - the final step echoes a successful commit instead of replacing it with a summary - const lastTool = msgs - .find((msg) => msg.info.id === lastAssistant?.id) - ?.parts.findLast((part) => part.type === "tool") const echoCommittedAnswer = - isLastStep && + (isLastStep || committedAnswer?.compacted) && format.type === "text" && agent.name === "analyst" && - lastAssistant?.parentID === lastUser.id && - lastAssistant.agent === "analyst" && - lastTool?.tool === "answer_commit" && - lastTool.state.status === "completed" + committedAnswer !== undefined + const committedOutputMissing = + echoCommittedAnswer && + !msgs.some((msg) => + msg.parts.some( + (part) => + part.type === "tool" && + part.tool === "answer_commit" && + part.state.status === "completed" && + !part.state.time.compacted && + part.state.output === committedAnswer!.output, + ), + ) // altimate_change end // altimate_change start - trace system prompt once per loop() call. @@ -1735,18 +1788,20 @@ export namespace SessionPrompt { system, messages: [ ...(await MessageV2.toModelMessages(msgs, model)), - ...(isLastStep + // altimate_change start - a compacted commit uses the next normal generation even before the step limit + ...(isLastStep || echoCommittedAnswer ? [ { role: "assistant" as const, // altimate_change start - preserve the committed answer within the existing final generation content: echoCommittedAnswer - ? "Tools are disabled. Reply with exactly the text returned by answer_commit and nothing else." + ? `Tools are disabled. Reply with exactly the text returned by answer_commit and nothing else.${committedOutputMissing ? `\n\n${committedAnswer!.output}` : ""}` : MAX_STEPS, // altimate_change end }, ] : []), + // altimate_change end ], // altimate_change start - the final echo cannot spend another step on tools tools: echoCommittedAnswer ? {} : tools, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index e3c606b1ec..4cf1eb2a7d 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -33,6 +33,9 @@ import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" import { Log } from "@/util/log" +// altimate_change start — reserved tool IDs must use the permission matcher's platform semantics. +import { Wildcard } from "@/util/wildcard" +// altimate_change end import { LspTool } from "./lsp" // altimate_change start — bridge legacy plugin tools to the Effect Tool API (v1.17.9) import { Effect } from "effect" @@ -508,8 +511,9 @@ export namespace ToolRegistry { ) // altimate_change end // altimate_change end - // altimate_change start — include plugin-provided custom tools - return [...resolved, ...custom, ...pluginCustom] + // altimate_change start — reserve the finalizer ID from file/plugin tools even when + // its native candidate is disabled or this catalogue is used without an analyst. + return [...resolved, ...custom, ...pluginCustom].filter((tool) => !Wildcard.match(tool.id, "answer_commit")) // altimate_change end } @@ -544,9 +548,8 @@ export namespace ToolRegistry { const tools = await all(plugins, configInput) // altimate_change end // altimate_change start — analyst-only finalization is a gated candidate, never a tool_lookup catalogue entry. - // A configured agent named analyst intentionally retains access. Append last so the session - // resolvers' tools[item.id] assignment gives the native tool precedence over custom/plugin collisions. - // MCP tools resolve afterward and can still replace this ID (e.g. server answer, tool commit). + // A configured agent named analyst intentionally retains access. External tools cannot + // use this reserved ID: all() excludes file/plugin collisions and MCP.tools excludes MCP collisions. if (agent?.name === "analyst" && process.env.ALTIMATE_ANSWER_COMMIT !== "0") { tools.push(await AppRuntime.runPromise(AnswerCommit.AnswerCommitTool)) } diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 7485ef9381..ac76379daf 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import path from "node:path" import { ProjectV2 } from "@opencode-ai/core/project" import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" @@ -251,4 +252,54 @@ describe("answer_commit agent scope", () => { expect((yield* registry.tools({ ...model, agent: analyst })).map((tool) => tool.id)).toContain("answer_commit") }), ) + + it.instance("answer_commit is reserved from plugin and file tools even when the native tool is disabled", () => + withLegacyInstance( + Effect.gen(function* () { + const tmp = yield* TestInstance + const agents = yield* Agent.Service + const analyst = yield* agents.get("analyst") + const builder = yield* agents.get("builder") + const filename = path.join(tmp.directory, "answer.ts") + yield* Effect.promise(() => + Bun.write( + filename, + `export const commit = { description: "File collision", args: {}, execute: async () => "External answer" }`, + ), + ) + const plugins = [ + { + tool: { + answer_commit: { description: "Plugin collision", args: {}, execute: async () => "External answer" }, + ordinary_tool: { description: "Ordinary plugin tool", args: {}, execute: async () => "OK" }, + }, + }, + ] + const config = { matches: [filename], config: {} } + const infos = yield* Effect.promise(() => ToolRegistry.allInfos(plugins, config)) + expect(infos.map((tool) => tool.id)).not.toContain("answer_commit") + expect(infos.map((tool) => tool.id)).toContain("ordinary_tool") + for (const toggle of ["0", "1"]) { + process.env.ALTIMATE_ANSWER_COMMIT = toggle + for (const agent of [analyst, builder]) { + const tools = yield* Effect.promise(() => ToolRegistry.tools(model, agent, plugins, undefined, config)) + expect(tools.filter((tool) => tool.id === "answer_commit")).toHaveLength( + toggle === "1" && agent.name === "analyst" ? 1 : 0, + ) + expect(tools.map((tool) => tool.id)).toContain("ordinary_tool") + } + } + const platform = Object.getOwnPropertyDescriptor(process, "platform")! + Object.defineProperty(process, "platform", { ...platform, value: "win32" }) + try { + const variants = [{ tool: { Answer_Commit: plugins[0].tool.answer_commit } }] + const infos = yield* Effect.promise(() => ToolRegistry.allInfos(variants, config)) + expect(Permission.evaluate("Answer_Commit", "*", analyst.permission).action).toBe("allow") + expect(infos.map((tool) => tool.id)).not.toContain("Answer_Commit") + } finally { + Object.defineProperty(process, "platform", platform) + } + }), + ), + ) }) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 77974ddd01..93eca27cac 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1358,6 +1358,57 @@ it.instance( }, ) +// altimate_change start — external tools cannot inherit the native finalizer's permission. +for (const toggle of ["1", "0"]) { + it.instance(`MCP reserves answer_commit with ALTIMATE_ANSWER_COMMIT=${toggle}`, () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + const previous = process.env.ALTIMATE_ANSWER_COMMIT + process.env.ALTIMATE_ANSWER_COMMIT = toggle + try { + lastCreatedClientName = "answer" + const state = getOrCreateClientState("answer") + state.tools = [ + { name: "commit", inputSchema: { type: "object", properties: {} } }, + { name: "lookup", inputSchema: { type: "object", properties: {} } }, + ] + yield* mcp.add("answer", { type: "local", command: ["echo", "test"] }) + expect(Object.keys(yield* mcp.tools())).toEqual(["answer_lookup"]) + expect(Object.keys((yield* mcp.snapshot("answer")).tools)).toEqual(["answer_lookup"]) + expect(state.requestCalls).toBe(0) + } finally { + if (previous === undefined) delete process.env.ALTIMATE_ANSWER_COMMIT + else process.env.ALTIMATE_ANSWER_COMMIT = previous + } + }), + ), + ) +} +// altimate_change end + +// altimate_change start — Windows permission matching treats tool IDs case-insensitively. +it.instance("MCP reserves case variants of answer_commit on Windows", () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "answer" + getOrCreateClientState("answer").tools = [ + { name: "Commit", inputSchema: { type: "object", properties: {} } }, + { name: "lookup", inputSchema: { type: "object", properties: {} } }, + ] + yield* mcp.add("answer", { type: "local", command: ["echo", "test"] }) + const platform = Object.getOwnPropertyDescriptor(process, "platform")! + Object.defineProperty(process, "platform", { ...platform, value: "win32" }) + try { + expect(Object.keys(yield* mcp.tools())).toEqual(["answer_lookup"]) + expect(Object.keys((yield* mcp.snapshot("answer")).tools)).toEqual(["answer_lookup"]) + } finally { + Object.defineProperty(process, "platform", platform) + } + }), + ), +) +// altimate_change end + // ======================================================================== // Test: transport leak — local stdio timeout (#19168) // ======================================================================== diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 42a19fdd14..d68a523b46 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -981,15 +981,45 @@ it.instance("learn file hooks append glob and anchor matches once and respect th // Loop semantics // altimate_change start - an answer committed before the step limit must still be echoed -for (const mode of ["committed", "failed", "structured"] as const) { +for (const mode of [ + "committed", + "failed", + "structured", + "compacted", + "compacted-usage", + "compacted-unlimited", + "compacted-new-request", + "compacted-synthetic-request", + "compacted-retry", +] as const) { + const compacts = mode.startsWith("compacted") + const newRequest = mode === "compacted-new-request" || mode === "compacted-synthetic-request" + const scenario = { + committed: "the exact final echo", + failed: "the failed-call limit", + structured: "the user output schema", + compacted: "the exact rows after automatic compaction", + "compacted-usage": "the exact rows after usage-triggered compaction", + "compacted-unlimited": "the exact rows after compaction without a step limit", + "compacted-new-request": "the newer user request after compaction", + "compacted-synthetic-request": "the newer synthetic user request after compaction", + "compacted-retry": "the exact rows after an echo overflow and compaction retry", + }[mode] noLLMServer.instance( - `answer_commit at the step limit preserves ${mode === "committed" ? "the exact final echo" : mode === "failed" ? "the failed-call limit" : "the user output schema"}`, + `answer_commit preserves ${scenario}`, () => Effect.gen(function* () { const { prompt, chat, sessions } = yield* boot() const requests: Record[] = [] - const answer = "42" - const args = mode === "failed" ? {} : { fields: [{ name: "", value: 42 }], answer_only: true } + const requestCount = mode === "compacted-retry" ? 5 : compacts ? 3 : 2 + const rows = Array.from({ length: 60 }, (_, index) => [`${index}: ${"x".repeat(500)}`]) + const answer = compacts ? ["| value |", "|---|", ...rows.map(([value]) => `| ${value} |`)].join("\n") : "42" + const args = + mode === "failed" + ? {} + : compacts + ? { rows: { columns: ["value"], values: rows }, answer_only: true } + : { fields: [{ name: "", value: 42 }], answer_only: true } const language = createOpenAICompatible({ name: "test", baseURL: "https://unused.invalid/v1", @@ -998,17 +1028,47 @@ for (const mode of ["committed", "failed", "structured"] as const) { if (typeof init?.body !== "string") throw new Error("expected a JSON request body") const request = JSON.parse(init.body) requests.push(request) - if (requests.length > 2) throw new Error("answer_commit exceeded the two-step budget") + if (requests.length > requestCount) throw new Error("answer_commit exceeded its generation budget") + if (mode === "compacted-retry" && requests.length === 3) { + return new Response(JSON.stringify({ error: { message: "request entity too large" } }), { + status: 413, + headers: { "Content-Type": "application/json" }, + }) + } + if (newRequest && requests.length === 2) { + const userID = MessageID.ascending() + await LegacySession.updateMessage({ + id: userID, + sessionID: chat.id, + role: "user", + agent: "analyst", + model: promptRef, + time: { created: Date.now() }, + }) + await LegacySession.updatePart({ + id: PartID.ascending(), + messageID: userID, + sessionID: chat.id, + type: "text", + synthetic: mode === "compacted-synthetic-request", + text: "A new question arrived during compaction; do not repeat the previous answer.", + }) + } const limited = JSON.stringify(request.messages).includes("CRITICAL - MAXIMUM STEPS REACHED") const response = requests.length === 1 - ? reply().tool("answer_commit", args).item() - : mode === "structured" - ? reply().tool("StructuredOutput", { count: 42 }).item() - : reply() - .text(limited ? "Maximum steps reached." : answer) - .stop() - .item() + ? reply() + .tool("answer_commit", args) + .usage({ input: mode === "compacted-usage" ? 80_000 : compacts ? 70_000 : 1, output: 1 }) + .item() + : compacts && (requests.length === 2 || (mode === "compacted-retry" && requests.length === 4)) + ? reply().text("The rows were committed. Continue with the final reply.").stop().item() + : mode === "structured" + ? reply().tool("StructuredOutput", { count: 42 }).item() + : reply() + .text(limited ? "Maximum steps reached." : answer) + .stop() + .item() if (response.type !== "sse") throw new Error("expected SSE response") return new Response( [...response.head, ...response.tail].map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + @@ -1042,23 +1102,32 @@ for (const mode of ["committed", "failed", "structured"] as const) { : {}), }) expect(result.info).not.toHaveProperty("error") - expect(requests).toHaveLength(2) + expect(requests).toHaveLength(requestCount) const messages = yield* sessions.messages({ sessionID: chat.id }) const commit = messages .flatMap((message) => message.parts) .find((part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "answer_commit") expect(commit?.state.status).toBe(mode === "failed" ? "error" : "completed") - if (mode === "committed") { - expect(JSON.stringify(requests[1].messages)).not.toContain("CRITICAL - MAXIMUM STEPS REACHED") - expect(requests[1].tools ?? []).toHaveLength(0) + if (mode === "committed" || (compacts && !newRequest)) { + const finalRequest = requests.at(-1)! + expect(JSON.stringify(finalRequest.messages)).not.toContain("CRITICAL - MAXIMUM STEPS REACHED") + expect(finalRequest.tools ?? []).toHaveLength(0) + if (!compacts) expect(requestText(requestMessages(finalRequest).at(-1)!.content)).not.toContain(answer) + if (compacts) { + expect(messages.some((message) => message.info.role === "assistant" && message.info.summary)).toBe(true) + expect( + requestMessages(finalRequest).some((message) => requestText(message.content).includes(answer)), + ).toBe(true) + expect(commit?.state.status === "completed" && commit.state.metadata.truncated).toBe(false) + } expect( result.parts .filter((part) => part.type === "text") .map((part) => part.text) .join(""), ).toBe(answer) - } else if (mode === "failed") { - expect(JSON.stringify(requests[1].messages)).toContain("CRITICAL - MAXIMUM STEPS REACHED") + } else if (mode === "failed" || newRequest) { + expect(JSON.stringify(requests.at(-1)!.messages)).toContain("CRITICAL - MAXIMUM STEPS REACHED") expect(result.parts).toContainEqual( expect.objectContaining({ type: "text", text: "Maximum steps reached." }), ) @@ -1075,7 +1144,9 @@ for (const mode of ["committed", "failed", "structured"] as const) { ...cfg, snapshot: false, learn: { enabled: false }, - agent: { analyst: { steps: 2, permission: { StructuredOutput: "allow" } } }, + agent: { + analyst: { steps: mode === "compacted-unlimited" ? undefined : 2, permission: { StructuredOutput: "allow" } }, + }, }, }, 30_000, From e8bc77c568484b136ade2bfd7bff9ac4e246cbc7 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 14:48:37 -0700 Subject: [PATCH 07/39] fix(analyst): restore the top-N tie contract and the answer-only fallback for `answer_commit` Two contract regressions raised in review: - The analyst prompt again says a top-N with a tie at the boundary returns exactly N concrete rows with a brief tie note; the tool description and the `rows` schema say the same, with `rows.note` as the place for the note. - The unavailable-tool fallback requires exactly the requested values or table in the requested format, with no label and no basis, for answer-only or specific-format requests; `answer_only` rendering stays label-free. Co-Authored-By: Claude Fable 5.1 --- .../opencode/src/altimate/prompts/analyst.txt | 2 +- .../src/altimate/tools/answer-commit.ts | 16 +++++++--- .../test/altimate/sql-validation-e2e.test.ts | 23 +++++++++++++-- .../test/altimate/tools/answer-commit.test.ts | 29 +++++++++++++++++-- 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index 952005f5c3..7b61b53d12 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -18,4 +18,4 @@ You are a trainable AI teammate. Check the "Teammate Training" section in your s If the user corrects your behavior, offer to save it as a rule using `training_save`. Use `training_list` to review learned knowledge. Skills: /teach, /train, /training-status. -Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else. Never list excluded candidates or alternative answers; when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly that, with no basis; never guess. +Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index ab6cef9477..6dc59edeb2 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -43,8 +43,15 @@ const field = z const rows = z .object({ columns: z.array(z.string()).min(1, "rows.columns requires at least one column"), - values: z.array(z.array(z.union([z.string(), number, z.null()]))).min(1, "rows.values requires at least one row"), - note: optional(z.string()), + values: z + .array(z.array(z.union([z.string(), number, z.null()]))) + .min(1, "rows.values requires at least one row") + .describe( + "Concrete result rows; when the request names N, the row count must equal N, even with a tie at the top-N boundary", + ), + note: optional(z.string()).describe( + "Brief result note, including any tie at the top-N boundary; omitted when answer_only is true", + ), }) .strict() .superRefine((rows, ctx) => { @@ -149,7 +156,8 @@ export function renderAnswer(payload: unknown): string { ].join("\n") return args.answer_only ? table : `${basis}**Answer:**\n${table}${args.rows.note ? `\n${args.rows.note}` : ""}` } - return `${basis}**Answer:** unable to determine — ${args.unresolved!.reason}` + const unresolved = `unable to determine — ${args.unresolved!.reason}` + return args.answer_only ? unresolved : `${basis}**Answer:** ${unresolved}` } // Keep the renderer and native Effect tool on the same parser, including filler normalization. @@ -164,7 +172,7 @@ export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ description: - "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", + "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", parameters: Parameters, // Preserve the existing wire schema; Zod and the SDK use different recursive JSON Schema types. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index d50d0e37af..a3cf25b216 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -101,10 +101,27 @@ describe("Tool name consistency in prompts", () => { }) }) - test("analyst prompt requires deterministic answer finalization and an unavailable-tool fallback", async () => { + test("analyst prompt requires deterministic answer finalization", async () => { const prompt = await Bun.file(path.join(import.meta.dir, "../../src/altimate/prompts/analyst.txt")).text() - expect(prompt.trim().split("\n\n").at(-1)).toBe( - "Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else. Never list excluded candidates or alternative answers; when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly that, with no basis; never guess.", + const paragraph = prompt.trim().split("\n\n").at(-1) + expect(paragraph).toContain( + "Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else.", + ) + expect(paragraph).toContain("Never list excluded candidates or alternative answers") + expect(paragraph).toContain("when the request asks for only the answer or a specific format, set `answer_only`") + }) + + test("analyst prompt preserves the top-N boundary-tie contract", async () => { + const prompt = await Bun.file(path.join(import.meta.dir, "../../src/altimate/prompts/analyst.txt")).text() + expect(prompt.trim().split("\n\n").at(-1)).toContain( + "a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`)", + ) + }) + + test("analyst prompt unavailable-tool fallback omits the answer_only label and basis", async () => { + const prompt = await Bun.file(path.join(import.meta.dir, "../../src/altimate/prompts/analyst.txt")).text() + expect(prompt.trim().split("\n\n").at(-1)).toContain( + "If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess.", ) }) diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index 5a352ceef8..38c5927cab 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -168,9 +168,9 @@ describe("renderAnswer golden text", () => { "Checked the requested period.\n\n**Answer:** unable to determine — No matching rows.", ], [ - "answer_only unresolved retains its status", + "answer_only unresolved retains its status without a label or basis", { answer_only: true, basis: "Checked the requested period.", unresolved: { reason: "No matching rows." } }, - "**Answer:** unable to determine — No matching rows.", + "unable to determine — No matching rows.", ], [ "two sentences and a decimal", @@ -405,6 +405,31 @@ test("answer_commit description defines answer_only and unused properties", asyn expect(tool.description).toContain("Omit unused optional properties") }) +test("answer_commit description preserves the top-N boundary-tie contract", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect(tool.description).toContain("When the request names N, rows.values must contain exactly N concrete rows") + expect(tool.description).toContain("even with a tie at the top-N boundary; note the tie briefly in rows.note") +}) + +test("answer_commit rows schema documents the requested count and boundary-tie note", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect(tool.jsonSchema?.properties?.rows).toMatchObject({ + anyOf: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + values: expect.objectContaining({ + description: + "Concrete result rows; when the request names N, the row count must equal N, even with a tie at the top-N boundary", + }), + note: expect.objectContaining({ + description: "Brief result note, including any tie at the top-N boundary; omitted when answer_only is true", + }), + }), + }), + ]), + }) +}) + test("answer_commit description gives the user output schema precedence", async () => { const tool = await initTool(AnswerCommit.AnswerCommitTool) expect(tool.description).toContain("not automatically removed when a user output schema is set") From 270a65e1230e4ab24754c068d8ebe1a02f2317d4 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 15:01:46 -0700 Subject: [PATCH 08/39] fix(analyst): `answer_commit` accepts boolean-like strings, requires a basis, and explains attribution Findings from live Claude sessions on Bedrock: - Every call failed validation because the model sent `answer_only` as the string `"true"`. Boolean fields now accept case-insensitive `"true"`/`"false"` and `1`/`0` at runtime; the published schema type stays boolean. - Sessions that dropped the previous "state your basis" sentence bound a policy clause about a related object to the requested object's attribution. `basis` is now required unless `answer_only` is set and must name population, grain and the source object/column that supplies the requested entity or value; the tool description and the analyst finishing paragraph say so and separate policy eligibility from metric and entity attribution. - Validation failures return a one-line error plus a minimal valid example payload so the next call can repair instead of repeating the same input. Co-Authored-By: Claude Fable 5.1 --- .../opencode/src/altimate/prompts/analyst.txt | 2 +- .../src/altimate/tools/answer-commit.ts | 31 +- .../opencode/test/agent/answer-commit.test.ts | 10 +- .../test/altimate/sql-validation-e2e.test.ts | 4 +- .../test/altimate/tools/answer-commit.test.ts | 313 ++++++++++++++---- 5 files changed, 295 insertions(+), 65 deletions(-) diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index 7b61b53d12..cda5d05094 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -18,4 +18,4 @@ You are a trainable AI teammate. Check the "Teammate Training" section in your s If the user corrects your behavior, offer to save it as a rule using `training_save`. Use `training_list` to review learned knowledge. Skills: /teach, /train, /training-status. -Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. +Finish by calling `answer_commit` with the single chosen value(s), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Include `basis` unless `answer_only` is true, even when unresolved; with `answer_only`, `basis` is optional and never rendered. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 6dc59edeb2..e65aeff6dd 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -86,6 +86,8 @@ const parameters = z .filter((part) => part.trim()).length <= 2, "basis must contain at most 2 sentences", ), + ).describe( + "basis is required unless answer_only is true: one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats; optional and never rendered with answer_only", ), answer_only: optional(z.boolean()), readings: optional( @@ -106,9 +108,24 @@ const parameters = z (value) => [value.fields, value.rows, value.unresolved].filter((shape) => shape !== undefined).length === 1, "Exactly one of fields, rows, or unresolved is required", ) + .refine((value) => value.answer_only === true || value.basis !== undefined, { + path: ["basis"], + message: + "basis is required unless answer_only is true: supply one or two sentences naming population, grain, source object/column, and caveats", + }) export type AnswerCommitInput = z.input +// 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) + function validationError(error: z.ZodError): string { const describe = (issues: z.core.$ZodIssue[], parent: PropertyKey[] = []): string[] => issues.flatMap((issue) => { @@ -118,7 +135,12 @@ function validationError(error: z.ZodError): string { return describe(issue.errors[0], path) return [`${path.join(".") || "answer_commit"}: ${issue.message}`] }) - return describe(error.issues).join("; ") + const message = describe(error.issues).join("; ").replace(/\s+/g, " ") + const example = { + fields: [{ name: "", value: 2 }], + basis: "Counted all records at record grain using records.id; no exclusions.", + } + return `${message}\nMinimal valid example: \`${JSON.stringify(example)}\`` } function tableCell(value: string | number | null): string { @@ -130,7 +152,7 @@ function tableCell(value: string | number | null): string { /** Deterministically finalize a data-question answer without changing session state. */ export function renderAnswer(payload: unknown): string { - const parsed = parameters.safeParse(payload) + const parsed = parser.safeParse(payload) if (!parsed.success) throw new Error(validationError(parsed.error)) const args = parsed.data const basis = !args.answer_only && args.basis ? `${args.basis}\n\n` : "" @@ -162,7 +184,7 @@ export function renderAnswer(payload: unknown): string { // Keep the renderer and native Effect tool on the same parser, including filler normalization. const Parameters = Schema.declareConstructor, AnswerCommitInput>()([], () => (input) => { - const parsed = parameters.safeParse(input) + const parsed = parser.safeParse(input) return parsed.success ? Effect.succeed(parsed.data) : Effect.fail(new SchemaIssue.InvalidValue(Option.some(input), { message: validationError(parsed.error) })) @@ -172,8 +194,9 @@ export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ description: - "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", + "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. basis is required unless answer_only is true: one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in `basis` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. With answer_only, basis is optional and never rendered. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", parameters: Parameters, + formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)), // Preserve the existing wire schema; Zod and the SDK use different recursive JSON Schema types. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion jsonSchema: z.toJSONSchema(parameters, { io: "input" }) as JSONSchema7, diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index ac76379daf..69f2f690d4 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -175,12 +175,18 @@ describe("answer_commit agent scope", () => { const result = yield* Effect.promise(() => Promise.resolve( tools.answer_commit.execute!( - { fields: [{ name: "Revenue", value: 42 }] }, + { + fields: [{ name: "Revenue", value: 42 }], + basis: "Revenue sums orders.amount across all orders, with one row per order.", + }, { toolCallId: "call_answer_commit_collision", messages: [], abortSignal: AbortSignal.any([]) }, ), ), ) - expect(result).toMatchObject({ title: "Answer committed", output: "**Answer:** Revenue: 42" }) + expect(result).toMatchObject({ + title: "Answer committed", + output: "Revenue sums orders.amount across all orders, with one row per order.\n\n**Answer:** Revenue: 42", + }) expect(pluginCalls).toBe(0) }), ), diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index a3cf25b216..3e89a7026a 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -105,8 +105,10 @@ describe("Tool name consistency in prompts", () => { const prompt = await Bun.file(path.join(import.meta.dir, "../../src/altimate/prompts/analyst.txt")).text() const paragraph = prompt.trim().split("\n\n").at(-1) expect(paragraph).toContain( - "Finish by calling `answer_commit` with the single chosen value(s) (or `unresolved` with the reason), then reply with exactly the text it returns and nothing else.", + "Finish by calling `answer_commit` with the single chosen value(s), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else.", ) + expect(paragraph).toContain("Include `basis` unless `answer_only` is true, even when unresolved") + expect(paragraph).toContain("with `answer_only`, `basis` is optional and never rendered") expect(paragraph).toContain("Never list excluded candidates or alternative answers") expect(paragraph).toContain("when the request asks for only the answer or a specific format, set `answer_only`") }) diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index 38c5927cab..4c06a693e3 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -13,13 +13,135 @@ test("answer_commit exposes the namespace projection", () => { expect(answerCommitModule).toHaveProperty("AnswerCommit.renderAnswer", AnswerCommit.renderAnswer) }) +describe("answer_commit boolean tolerance", () => { + test.each([true, "true", "TRUE", "TrUe", 1])("answer_only %s suppresses basis and labels", async (answer_only) => { + const payload = { + answer_only, + basis: "Counted records at record grain using records.id.", + fields: [{ name: "Count", value: 2 }], + } + expect(AnswerCommit.renderAnswer(payload)).toBe("2") + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect((await tool.execute(payload, context)).output).toBe("2") + }) + + test.each([false, "false", "FALSE", "FaLsE", 0])("answer_only %s renders the basis", async (answer_only) => { + const basis = "Counted records at record grain using records.id." + const payload = { answer_only, basis, fields: [{ name: "Count", value: 2 }] } + expect(AnswerCommit.renderAnswer(payload)).toBe(`${basis}\n\n**Answer:** Count: 2`) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect((await tool.execute(payload, context)).output).toBe(`${basis}\n\n**Answer:** Count: 2`) + }) + + test.each(["yes", "no", "1", "0", 2, -1])("rejects invalid answer_only %s", async (answer_only) => { + const payload = { + answer_only, + basis: "Counted records at record grain using records.id.", + fields: [{ name: "", value: 2 }], + } + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(/answer_only.*boolean/i) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + await rejects(tool.execute(payload, context), /answer_only.*boolean/i) + }) + + test("keeps the advertised answer_only value boolean", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect(tool.jsonSchema?.properties?.answer_only).toMatchObject({ anyOf: [{ type: "boolean" }, {}, {}, {}, {}] }) + }) +}) + +describe("answer_commit required basis", () => { + test.each([undefined, false, "false", 0, null, "", [], {}].map((value) => [value]))( + "answer_only %j requires basis for every answer shape", + async (answer_only) => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + for (const shape of [ + { fields: [{ name: "", value: 2 }] }, + { rows: { columns: ["Count"], values: [[2]] } }, + { unresolved: { reason: "No matching records." } }, + ]) { + const payload = { ...shape, answer_only } + const error = /basis.*required.*population.*grain.*source object\/column.*caveats/i + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(error) + await rejects(tool.execute(payload, context), error) + } + }, + ) + + test.each([undefined, null, "", " \t\r\n ", [], {}].map((value) => [value]))( + "basis filler %j cannot replace a required basis", + async (basis) => { + const payload = { fields: [{ name: "", value: 2 }], basis } + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(/basis.*required/i) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + await rejects(tool.execute(payload, context), /basis.*required/i) + }, + ) + + test.each([undefined, null, "", " \t\r\n ", [], {}].map((value) => [value]))( + "answer_only allows absent basis %j", + async (basis) => { + const payload = { answer_only: "true", fields: [{ name: "Count", value: 2 }], basis } + expect(AnswerCommit.renderAnswer(payload)).toBe("2") + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect((await tool.execute(payload, context)).output).toBe("2") + }, + ) + + test("description and schema explain the basis requirement and attribution source", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect(tool.description).toContain("basis is required unless answer_only is true") + expect(tool.description).toContain( + "one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats", + ) + expect(tool.description).toContain( + "Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in `basis` which source object and column supply it.", + ) + expect(structuredClone(tool.jsonSchema?.properties?.basis)).toMatchObject({ + description: expect.stringMatching( + /required unless answer_only.*population.*grain.*source object\/column.*caveats/i, + ), + }) + }) +}) + +describe("answer_commit validation repair", () => { + test.each([ + ["invalid boolean", { answer_only: "yes", fields: [{ name: "", value: 2 }] }, /answer_only/], + ["missing basis", { fields: [{ name: "", value: 2 }] }, /basis/], + ["missing value", { fields: [{ name: "Count" }] }, /fields.*value/], + ["missing shape", {}, /fields, rows, or unresolved/], + ] as [string, unknown, RegExp][])( + "%s returns a one-line error and a valid repair example", + async (_name, payload, expected) => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + for (const execute of [() => AnswerCommit.renderAnswer(payload), () => tool.execute(payload, context)]) { + let message = "" + try { + await execute() + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + const lines = message.split("\n") + expect(lines[0]).toMatch(expected) + expect(lines[1]).toStartWith("Minimal valid example: ") + const exampleText = lines[1]!.match(/^Minimal valid example: `(.*)`\.?$/)?.[1] + expect(exampleText).toBeDefined() + const example = JSON.parse(exampleText!) + expect(AnswerCommit.renderAnswer(example)).toBeString() + expect((await tool.execute(example, context)).output).toBe(AnswerCommit.renderAnswer(example)) + } + }, + ) +}) + test("answer_commit executes synchronously with native Effect permissions", async () => { const info = await toolInfo(AnswerCommit.AnswerCommitTool) const tool = await Effect.runPromise(info.init()) const asks: unknown[] = [] const result = Effect.runSync( tool.execute( - { fields: [{ name: "Count", value: 2 }] }, + { fields: [{ name: "Count", value: 2 }], basis: "Counted records at record grain using records.id." }, { ...context, sessionID: SessionID.make("ses_test"), @@ -29,10 +151,12 @@ test("answer_commit executes synchronously with native Effect permissions", asyn }, ), ) - expect(result.output).toBe("**Answer:** Count: 2") + expect(result.output).toBe("Counted records at record grain using records.id.\n\n**Answer:** Count: 2") expect(asks).toEqual([{ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }]) }) +const recordBasis = "Used all records at record grain from records.value." + describe("answer_commit numeric safety", () => { test.each(["9007199254740993", "-9007199254740993", "1e30"])("rejects unsafe field number %s", async (value) => { const payload = JSON.parse(`{"fields":[{"name":"Count","value":${value}}]}`) @@ -52,20 +176,30 @@ describe("answer_commit numeric safety", () => { "preserves safe numbers and numeric strings: %s", async (value) => { const tool = await initTool(AnswerCommit.AnswerCommitTool) - const fields = { fields: [{ name: "", value }] } - const rows = { rows: { columns: ["Count"], values: [[value]] } } - expect(AnswerCommit.renderAnswer(fields)).toBe(`**Answer:** ${value}`) - expect((await tool.execute(fields, context)).output).toBe(`**Answer:** ${value}`) - expect(AnswerCommit.renderAnswer(rows)).toBe(`**Answer:**\n| Count |\n|---|\n| ${value} |`) - expect((await tool.execute(rows, context)).output).toBe(`**Answer:**\n| Count |\n|---|\n| ${value} |`) + const fields = { basis: recordBasis, fields: [{ name: "", value }] } + const rows = { basis: recordBasis, rows: { columns: ["Count"], values: [[value]] } } + expect(AnswerCommit.renderAnswer(fields)).toBe(`${recordBasis}\n\n**Answer:** ${value}`) + expect((await tool.execute(fields, context)).output).toBe(`${recordBasis}\n\n**Answer:** ${value}`) + expect(AnswerCommit.renderAnswer(rows)).toBe(`${recordBasis}\n\n**Answer:**\n| Count |\n|---|\n| ${value} |`) + expect((await tool.execute(rows, context)).output).toBe( + `${recordBasis}\n\n**Answer:**\n| Count |\n|---|\n| ${value} |`, + ) }, ) }) describe("renderAnswer golden text", () => { const cases: [string, unknown, string][] = [ - ["single unnamed field", { fields: [{ name: "", value: "West" }] }, "**Answer:** West"], - ["single named field", { fields: [{ name: "Region", value: "West" }] }, "**Answer:** Region: West"], + [ + "single unnamed field", + { basis: recordBasis, fields: [{ name: "", value: "West" }] }, + `${recordBasis}\n\n**Answer:** West`, + ], + [ + "single named field", + { basis: recordBasis, fields: [{ name: "Region", value: "West" }] }, + `${recordBasis}\n\n**Answer:** Region: West`, + ], [ "basis and units", { basis: "Summed completed orders.", fields: [{ name: "Revenue", value: 42, units: "USD" }] }, @@ -74,19 +208,44 @@ describe("renderAnswer golden text", () => { [ "multiple fields", { + basis: recordBasis, fields: [ { name: "Orders", value: 3 }, { name: "Revenue", value: 12.5, units: "USD" }, ], }, - "**Answer:** Orders: 3; Revenue: 12.5 USD", + `${recordBasis}\n\n**Answer:** Orders: 3; Revenue: 12.5 USD`, + ], + [ + "fixed numeric precision", + { basis: recordBasis, fields: [{ name: "Rate", value: 1.2, precision: 3 }] }, + `${recordBasis}\n\n**Answer:** Rate: 1.200`, + ], + [ + "zero precision", + { basis: recordBasis, fields: [{ name: "Rate", value: 1.6, precision: 0 }] }, + `${recordBasis}\n\n**Answer:** Rate: 2`, + ], + [ + "maximum precision", + { basis: recordBasis, fields: [{ name: "", value: 0.5, precision: 10 }] }, + `${recordBasis}\n\n**Answer:** 0.5000000000`, + ], + [ + "integers stay integers", + { basis: recordBasis, fields: [{ name: "", value: 7, precision: 2 }] }, + `${recordBasis}\n\n**Answer:** 7`, + ], + [ + "zero is a value", + { basis: recordBasis, fields: [{ name: "", value: 0, precision: 2 }] }, + `${recordBasis}\n\n**Answer:** 0`, + ], + [ + "precision leaves text intact", + { basis: recordBasis, fields: [{ name: "", value: "001.20", precision: 1 }] }, + `${recordBasis}\n\n**Answer:** 001.20`, ], - ["fixed numeric precision", { fields: [{ name: "Rate", value: 1.2, precision: 3 }] }, "**Answer:** Rate: 1.200"], - ["zero precision", { fields: [{ name: "Rate", value: 1.6, precision: 0 }] }, "**Answer:** Rate: 2"], - ["maximum precision", { fields: [{ name: "", value: 0.5, precision: 10 }] }, "**Answer:** 0.5000000000"], - ["integers stay integers", { fields: [{ name: "", value: 7, precision: 2 }] }, "**Answer:** 7"], - ["zero is a value", { fields: [{ name: "", value: 0, precision: 2 }] }, "**Answer:** 0"], - ["precision leaves text intact", { fields: [{ name: "", value: "001.20", precision: 1 }] }, "**Answer:** 001.20"], [ "answer_only single field", { @@ -108,8 +267,9 @@ describe("renderAnswer golden text", () => { "3; 12 USD", ], [ - "rows without basis", + "rows with basis", { + basis: recordBasis, rows: { columns: ["Region", "Orders"], values: [ @@ -118,7 +278,7 @@ describe("renderAnswer golden text", () => { ], }, }, - "**Answer:**\n| Region | Orders |\n|---|---|\n| West | 3 |\n| East | 2 |", + `${recordBasis}\n\n**Answer:**\n| Region | Orders |\n|---|---|\n| West | 3 |\n| East | 2 |`, ], [ "rows with basis and note", @@ -139,28 +299,38 @@ describe("renderAnswer golden text", () => { ], [ "null and empty table cells", - { rows: { columns: ["Value"], values: [[null], [""]] } }, - "**Answer:**\n| Value |\n|---|\n| null |\n| |", + { basis: recordBasis, rows: { columns: ["Value"], values: [[null], [""]] } }, + `${recordBasis}\n\n**Answer:**\n| Value |\n|---|\n| null |\n| |`, ], [ "table cells preserve their boundaries", - { rows: { columns: ["A|B"], values: [["first\nsecond|third"]] } }, - "**Answer:**\n| A\\|B |\n|---|\n| first second\\|third |", + { basis: recordBasis, rows: { columns: ["A|B"], values: [["first\nsecond|third"]] } }, + `${recordBasis}\n\n**Answer:**\n| A\\|B |\n|---|\n| first second\\|third |`, ], [ "table headers and cells escape backslashes before pipes", - { rows: { columns: [String.raw`A\|B`, String.raw`C\\|D`], values: [[String.raw`a\|b`, String.raw`c\\|d`]] } }, - ["**Answer:**", String.raw`| A\\\|B | C\\\\\|D |`, "|---|---|", String.raw`| a\\\|b | c\\\\\|d |`].join("\n"), + { + basis: recordBasis, + rows: { columns: [String.raw`A\|B`, String.raw`C\\|D`], values: [[String.raw`a\|b`, String.raw`c\\|d`]] }, + }, + [ + recordBasis, + "", + "**Answer:**", + String.raw`| A\\\|B | C\\\\\|D |`, + "|---|---|", + String.raw`| a\\\|b | c\\\\\|d |`, + ].join("\n"), ], [ "table headers and cells flatten all line endings to spaces", - { rows: { columns: ["A\r\nB\rC\nD"], values: [["a\r\nb\rc\nd"]] } }, - "**Answer:**\n| A B C D |\n|---|\n| a b c d |", + { basis: recordBasis, rows: { columns: ["A\r\nB\rC\nD"], values: [["a\r\nb\rc\nd"]] } }, + `${recordBasis}\n\n**Answer:**\n| A B C D |\n|---|\n| a b c d |`, ], [ - "unresolved without basis", - { unresolved: { reason: "No matching rows." } }, - "**Answer:** unable to determine — No matching rows.", + "answer_only unresolved without basis", + { answer_only: true, unresolved: { reason: "No matching rows." } }, + "unable to determine — No matching rows.", ], [ "unresolved with basis", @@ -188,31 +358,47 @@ describe("renderAnswer golden text", () => { fields: [{ name: "", value: 2, units: "", precision: "" }], rows: {}, unresolved: "", - basis: "", + basis: recordBasis, readings: [], answer_only: "", }, - "**Answer:** 2", + `${recordBasis}\n\n**Answer:** 2`, ], [ "unused array fillers", - { fields: [], rows: { columns: ["Count"], values: [[2]], note: "" }, unresolved: {}, basis: [], readings: "" }, - "**Answer:**\n| Count |\n|---|\n| 2 |", + { + answer_only: true, + fields: [], + rows: { columns: ["Count"], values: [[2]], note: "" }, + unresolved: {}, + basis: [], + readings: "", + }, + "| Count |\n|---|\n| 2 |", + ], + [ + "whitespace-only basis is absent with answer_only", + { answer_only: true, basis: " \t\r\n ", fields: [{ name: "", value: 2 }] }, + "2", + ], + [ + "whitespace-only units are absent", + { basis: recordBasis, fields: [{ name: "", value: 2, units: " \t\r\n " }] }, + `${recordBasis}\n\n**Answer:** 2`, ], - ["whitespace-only basis is absent", { basis: " \t\r\n ", fields: [{ name: "", value: 2 }] }, "**Answer:** 2"], - ["whitespace-only units are absent", { fields: [{ name: "", value: 2, units: " \t\r\n " }] }, "**Answer:** 2"], [ "whitespace-only note is absent", - { rows: { columns: ["Count"], values: [[2]], note: " \t\r\n " } }, - "**Answer:**\n| Count |\n|---|\n| 2 |", + { basis: recordBasis, rows: { columns: ["Count"], values: [[2]], note: " \t\r\n " } }, + `${recordBasis}\n\n**Answer:**\n| Count |\n|---|\n| 2 |`, ], [ "advisory readings do not render", { + basis: recordBasis, fields: [{ name: "", value: 2 }], readings: [{ clause: "month", reading: "calendar month", result_summary: "2", decided_by: "request" }], }, - "**Answer:** 2", + `${recordBasis}\n\n**Answer:** 2`, ], ] @@ -225,20 +411,28 @@ describe("renderAnswer golden text", () => { describe("renderAnswer strict-schema null fillers", () => { test.each(["precision", "units"])("null %s is absent", (key) => { - expect(AnswerCommit.renderAnswer({ fields: [{ name: "", value: 1.25, [key]: null }] })).toBe("**Answer:** 1.25") + expect(AnswerCommit.renderAnswer({ basis: recordBasis, fields: [{ name: "", value: 1.25, [key]: null }] })).toBe( + `${recordBasis}\n\n**Answer:** 1.25`, + ) }) - test.each(["basis", "readings", "answer_only"])("null %s is absent", (key) => { - expect(AnswerCommit.renderAnswer({ fields: [{ name: "Count", value: 2 }], [key]: null })).toBe( - "**Answer:** Count: 2", + test("null basis is absent with answer_only", () => { + expect(AnswerCommit.renderAnswer({ answer_only: true, fields: [{ name: "Count", value: 2 }], basis: null })).toBe( + "2", ) }) - test("null note is absent", () => { - expect(AnswerCommit.renderAnswer({ rows: { columns: ["Count"], values: [[2]], note: null } })).toBe( - "**Answer:**\n| Count |\n|---|\n| 2 |", + test.each(["readings", "answer_only"])("null %s is absent", (key) => { + expect(AnswerCommit.renderAnswer({ basis: recordBasis, fields: [{ name: "Count", value: 2 }], [key]: null })).toBe( + `${recordBasis}\n\n**Answer:** Count: 2`, ) }) + + test("null note is absent", () => { + expect( + AnswerCommit.renderAnswer({ basis: recordBasis, rows: { columns: ["Count"], values: [[2]], note: null } }), + ).toBe(`${recordBasis}\n\n**Answer:**\n| Count |\n|---|\n| 2 |`) + }) }) describe("renderAnswer basis sentence boundaries", () => { @@ -291,9 +485,13 @@ describe("renderAnswer basis sentence boundaries", () => { describe("renderAnswer structural validation", () => { test("optional array fillers accept only empty arrays", async () => { - expect(AnswerCommit.renderAnswer({ fields: [{ name: "", value: 2, precision: [], units: [] }], basis: [] })).toBe( - "**Answer:** 2", - ) + expect( + AnswerCommit.renderAnswer({ + answer_only: true, + fields: [{ name: "", value: 2, precision: [], units: [] }], + basis: [], + }), + ).toBe("2") for (const filler of [[null], [0], [""], [{}], [[]]]) { expect(() => AnswerCommit.renderAnswer({ fields: [{ name: "", value: 2, precision: filler }] })).toThrow( /precision/i, @@ -365,7 +563,7 @@ test("answer_commit returns only the rendered answer and advisory metadata", asy const readings = [{ clause: "month", reading: "calendar month", result_summary: "2", decided_by: "request" }] const asks: unknown[] = [] const result = await tool.execute( - { fields: [{ name: "", value: 2 }], rows: {}, unresolved: "", basis: "", readings }, + { fields: [{ name: "", value: 2 }], rows: {}, unresolved: "", basis: recordBasis, readings }, { ...context, ask: (input: unknown) => { @@ -373,7 +571,7 @@ test("answer_commit returns only the rendered answer and advisory metadata", asy }, }, ) - expect(result.output).toBe("**Answer:** 2") + expect(result.output).toBe(`${recordBasis}\n\n**Answer:** 2`) expect(result.metadata.readings).toEqual(readings) expect(asks).toEqual([{ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }]) }) @@ -413,7 +611,8 @@ test("answer_commit description preserves the top-N boundary-tie contract", asyn test("answer_commit rows schema documents the requested count and boundary-tie note", async () => { const tool = await initTool(AnswerCommit.AnswerCommitTool) - expect(tool.jsonSchema?.properties?.rows).toMatchObject({ + // Bun's asymmetric matchers mutate nested values; preserve the shared tool schema. + expect(structuredClone(tool.jsonSchema?.properties?.rows)).toMatchObject({ anyOf: expect.arrayContaining([ expect.objectContaining({ properties: expect.objectContaining({ @@ -441,13 +640,13 @@ test("answer_commit accepts null optional fields through the tool wrapper", asyn const result = await tool.execute( { fields: [{ name: "", value: 1.25, precision: null, units: null }], - basis: null, + basis: recordBasis, readings: null, answer_only: null, }, context, ) - expect(result.output).toBe("**Answer:** 1.25") + expect(result.output).toBe(`${recordBasis}\n\n**Answer:** 1.25`) expect(result.metadata.readings).toEqual([]) }) @@ -460,7 +659,7 @@ test("answer_commit names invalid field values through the tool wrapper", async test("answer_commit refuses direct execution by another agent", async () => { const tool = await initTool(AnswerCommit.AnswerCommitTool) - await expect(tool.execute({ fields: [{ name: "", value: 2 }] }, { ...context, agent: "builder" })).rejects.toThrow( - /only.*analyst/i, - ) + await expect( + tool.execute({ basis: recordBasis, fields: [{ name: "", value: 2 }] }, { ...context, agent: "builder" }), + ).rejects.toThrow(/only.*analyst/i) }) From 2f93220b1c3c0ccfd7e743063228416cb9c44a55 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 15:11:54 -0700 Subject: [PATCH 09/39] fix(analyst): do not duplicate the answer label when the single field is named Answer A single field named Answer, Result or Value (or left blank) renders as the plain answer line instead of repeating the label. Co-Authored-By: Claude Fable 5.1 --- .../src/altimate/tools/answer-commit.ts | 24 ++++++-- .../test/altimate/tools/answer-commit.test.ts | 59 ++++++++++++++++++- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index e65aeff6dd..1dc6643735 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -26,10 +26,7 @@ const number = z const field = z .object({ - name: z - .string() - .refine((value) => value === "" || value.trim().length > 0, "Field name must be non-empty") - .describe("Field label; use an empty name for an unnamed value"), + name: z.string().describe("Field label; use an empty name for an unnamed value"), value: z .union([z.string(), number], { error: "Each field value must be one non-empty string or number" }) .refine((value) => typeof value !== "string" || value.trim().length > 0, "Field value must be non-empty"), @@ -68,7 +65,20 @@ const rows = z const parameters = z .object({ - fields: optional(z.array(field)), + fields: optional( + z.array(field).superRefine((fields, ctx) => { + if (fields.length === 1) return + fields.forEach((field, index) => { + if (field.name !== "" && !field.name.trim()) { + ctx.addIssue({ + code: "custom", + path: [index, "name"], + message: "Field name must be non-empty", + }) + } + }) + }), + ), rows: optional(rows), unresolved: optional( z @@ -157,13 +167,15 @@ export function renderAnswer(payload: unknown): string { const args = parsed.data const basis = !args.answer_only && args.basis ? `${args.basis}\n\n` : "" if (args.fields) { + const single = args.fields.length === 1 const values = args.fields .map((field) => { const value = typeof field.value === "number" && field.precision !== undefined && !Number.isInteger(field.value) ? field.value.toFixed(field.precision) : String(field.value) - const name = !args.answer_only && field.name ? `${field.name}: ` : "" + const redundant = single && ["", "answer", "result", "value"].includes(field.name.trim().toLowerCase()) + const name = !args.answer_only && field.name && !redundant ? `${field.name}: ` : "" return `${name}${value}${field.units ? ` ${field.units}` : ""}` }) .join("; ") diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index 4c06a693e3..2afdb6e4c0 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -200,6 +200,54 @@ describe("renderAnswer golden text", () => { { basis: recordBasis, fields: [{ name: "Region", value: "West" }] }, `${recordBasis}\n\n**Answer:** Region: West`, ], + [ + "single empty name with units", + { basis: recordBasis, fields: [{ name: "", value: "42", units: "widgets" }] }, + `${recordBasis}\n\n**Answer:** 42 widgets`, + ], + [ + "single whitespace-only name with units", + { basis: recordBasis, fields: [{ name: " \t\r\n ", value: "42", units: "widgets" }] }, + `${recordBasis}\n\n**Answer:** 42 widgets`, + ], + [ + "single Answer name with units", + { basis: recordBasis, fields: [{ name: "Answer", value: "42", units: "widgets" }] }, + `${recordBasis}\n\n**Answer:** 42 widgets`, + ], + [ + "single padded mixed-case answer name with units", + { basis: recordBasis, fields: [{ name: " aNsWeR ", value: "42", units: "widgets" }] }, + `${recordBasis}\n\n**Answer:** 42 widgets`, + ], + [ + "single uppercase result name with units", + { basis: recordBasis, fields: [{ name: "RESULT", value: "42", units: "widgets" }] }, + `${recordBasis}\n\n**Answer:** 42 widgets`, + ], + [ + "single padded mixed-case value name with units", + { basis: recordBasis, fields: [{ name: " vAlUe ", value: "42", units: "widgets" }] }, + `${recordBasis}\n\n**Answer:** 42 widgets`, + ], + [ + "single real name with units keeps its label", + { basis: recordBasis, fields: [{ name: "Widget count", value: "42", units: "widgets" }] }, + `${recordBasis}\n\n**Answer:** Widget count: 42 widgets`, + ], + [ + "multiple fields keep generic labels", + { + basis: recordBasis, + fields: [ + { name: "Answer", value: "42", units: "widgets" }, + { name: "RESULT", value: 2 }, + { name: " vAlUe ", value: 3 }, + { name: "", value: 4 }, + ], + }, + `${recordBasis}\n\n**Answer:** Answer: 42 widgets; RESULT: 2; vAlUe : 3; 4`, + ], [ "basis and units", { basis: "Summed completed orders.", fields: [{ name: "Revenue", value: 42, units: "USD" }] }, @@ -511,7 +559,16 @@ describe("renderAnswer structural validation", () => { ["empty value", { fields: [{ name: "Count", value: "" }] }, /fields.*value.*non-empty/i], ["blank value", { fields: [{ name: "Count", value: " " }] }, /fields.*value.*non-empty/i], ["whitespace-only value", { fields: [{ name: "Count", value: " \t\r\n " }] }, /fields.*value.*non-empty/i], - ["whitespace-only name", { fields: [{ name: " \t\r\n ", value: 2 }] }, /fields.*name.*non-empty/i], + [ + "whitespace-only name in multiple fields", + { + fields: [ + { name: " \t\r\n ", value: 2 }, + { name: "Count", value: 3 }, + ], + }, + /fields.*name.*non-empty/i, + ], ["multiple values in one field", { fields: [{ name: "Count", value: [1, 2] }] }, /fields.*value.*one/i], ["missing value", { fields: [{ name: "Count" }] }, /fields.*value/i], ["null field value", { fields: [{ name: "Count", value: null }] }, /fields.*value/i], From 83edc6925f9086d346137bc951827c739000359d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 16:02:23 -0700 Subject: [PATCH 10/39] fix(analyst): require a basis on every answer_commit payload The basis (population, grain, source object/column) is the model's own reasoning input; it is required even for answer-only requests, where it is validated but not rendered. Live sessions that skipped it committed an entity from a related object instead of the requested one. Co-Authored-By: Claude Fable 5.1 --- .../opencode/src/altimate/prompts/analyst.txt | 2 +- .../src/altimate/tools/answer-commit.ts | 22 +++--- .../test/altimate/sql-validation-e2e.test.ts | 4 +- .../test/altimate/tools/answer-commit.test.ts | 74 ++++++++++--------- packages/opencode/test/session/prompt.test.ts | 12 ++- 5 files changed, 65 insertions(+), 49 deletions(-) diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index cda5d05094..4f422bba7a 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -18,4 +18,4 @@ You are a trainable AI teammate. Check the "Teammate Training" section in your s If the user corrects your behavior, offer to save it as a rule using `training_save`. Use `training_list` to review learned knowledge. Skills: /teach, /train, /training-status. -Finish by calling `answer_commit` with the single chosen value(s), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Include `basis` unless `answer_only` is true, even when unresolved; with `answer_only`, `basis` is optional and never rendered. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. +Finish by calling `answer_commit` with the single chosen value(s), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Always supply `basis`, even when unresolved; it is never shown when the request asks for only the answer. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 1dc6643735..3a79c902cf 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -63,6 +63,9 @@ const rows = z }) }) +const basisRequired = + "basis is always required: supply one or two sentences naming population, grain, source object/column, and caveats" + const parameters = z .object({ fields: optional( @@ -87,18 +90,20 @@ const parameters = z }) .strict(), ), - basis: optional( - z.string().refine( + basis: z + .string({ error: basisRequired }) + .refine((value) => value.trim().length > 0, basisRequired) + .refine( (value) => value .replace(/\b(?:e\.g|i\.e|etc|Dr|vs|approx)\./gi, (word) => word.replaceAll(".", "")) .split(/[.?!](?:\s+(?=\S)|$)/u) .filter((part) => part.trim()).length <= 2, "basis must contain at most 2 sentences", + ) + .describe( + "basis is always required: one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats; never rendered with answer_only", ), - ).describe( - "basis is required unless answer_only is true: one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats; optional and never rendered with answer_only", - ), answer_only: optional(z.boolean()), readings: optional( z.array( @@ -118,11 +123,6 @@ const parameters = z (value) => [value.fields, value.rows, value.unresolved].filter((shape) => shape !== undefined).length === 1, "Exactly one of fields, rows, or unresolved is required", ) - .refine((value) => value.answer_only === true || value.basis !== undefined, { - path: ["basis"], - message: - "basis is required unless answer_only is true: supply one or two sentences naming population, grain, source object/column, and caveats", - }) export type AnswerCommitInput = z.input @@ -206,7 +206,7 @@ export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ description: - "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. basis is required unless answer_only is true: one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in `basis` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. With answer_only, basis is optional and never rendered. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", + "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Always supply `basis`; it is never shown when the request asks for only the answer. Supply one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in `basis` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", parameters: Parameters, formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)), // Preserve the existing wire schema; Zod and the SDK use different recursive JSON Schema types. diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index 3e89a7026a..8832255df6 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -107,8 +107,8 @@ describe("Tool name consistency in prompts", () => { expect(paragraph).toContain( "Finish by calling `answer_commit` with the single chosen value(s), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else.", ) - expect(paragraph).toContain("Include `basis` unless `answer_only` is true, even when unresolved") - expect(paragraph).toContain("with `answer_only`, `basis` is optional and never rendered") + expect(paragraph).toContain("Always supply `basis`, even when unresolved") + expect(paragraph).toContain("it is never shown when the request asks for only the answer") expect(paragraph).toContain("Never list excluded candidates or alternative answers") expect(paragraph).toContain("when the request asks for only the answer or a specific format, set `answer_only`") }) diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index 2afdb6e4c0..c6bbcd8216 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -51,7 +51,7 @@ describe("answer_commit boolean tolerance", () => { }) describe("answer_commit required basis", () => { - test.each([undefined, false, "false", 0, null, "", [], {}].map((value) => [value]))( + test.each([undefined, false, "false", 0, null, "", [], {}, true, "true", "TRUE", "TrUe", 1].map((value) => [value]))( "answer_only %j requires basis for every answer shape", async (answer_only) => { const tool = await initTool(AnswerCommit.AnswerCommitTool) @@ -79,18 +79,20 @@ describe("answer_commit required basis", () => { ) test.each([undefined, null, "", " \t\r\n ", [], {}].map((value) => [value]))( - "answer_only allows absent basis %j", + "answer_only rejects absent basis %j", async (basis) => { const payload = { answer_only: "true", fields: [{ name: "Count", value: 2 }], basis } - expect(AnswerCommit.renderAnswer(payload)).toBe("2") + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(/basis.*required/i) const tool = await initTool(AnswerCommit.AnswerCommitTool) - expect((await tool.execute(payload, context)).output).toBe("2") + await rejects(tool.execute(payload, context), /basis.*required/i) }, ) test("description and schema explain the basis requirement and attribution source", async () => { const tool = await initTool(AnswerCommit.AnswerCommitTool) - expect(tool.description).toContain("basis is required unless answer_only is true") + expect(tool.description).toContain( + "Always supply `basis`; it is never shown when the request asks for only the answer", + ) expect(tool.description).toContain( "one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats", ) @@ -98,10 +100,12 @@ describe("answer_commit required basis", () => { "Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in `basis` which source object and column supply it.", ) expect(structuredClone(tool.jsonSchema?.properties?.basis)).toMatchObject({ + type: "string", description: expect.stringMatching( - /required unless answer_only.*population.*grain.*source object\/column.*caveats/i, + /always required.*population.*grain.*source object\/column.*caveats.*never rendered with answer_only/i, ), }) + expect(tool.jsonSchema?.required).toContain("basis") }) }) @@ -109,8 +113,23 @@ describe("answer_commit validation repair", () => { test.each([ ["invalid boolean", { answer_only: "yes", fields: [{ name: "", value: 2 }] }, /answer_only/], ["missing basis", { fields: [{ name: "", value: 2 }] }, /basis/], + [ + "answer_only fields missing basis", + { answer_only: true, fields: [{ name: "Id", value: "requested-id" }] }, + /basis/, + ], + [ + "answer_only rows missing basis", + { answer_only: true, rows: { columns: ["Id"], values: [["requested-id"]] } }, + /basis/, + ], + [ + "answer_only unresolved missing basis", + { answer_only: true, unresolved: { reason: "No matching records." } }, + /basis/, + ], ["missing value", { fields: [{ name: "Count" }] }, /fields.*value/], - ["missing shape", {}, /fields, rows, or unresolved/], + ["missing shape", { basis: "Counted records at record grain using records.id." }, /fields, rows, or unresolved/], ] as [string, unknown, RegExp][])( "%s returns a one-line error and a valid repair example", async (_name, payload, expected) => { @@ -297,7 +316,7 @@ describe("renderAnswer golden text", () => { [ "answer_only single field", { - basis: "This basis is omitted.", + basis: "Summed all orders at order grain using orders.amount.", answer_only: true, fields: [{ name: "Revenue", value: 1.2, units: "USD", precision: 2 }], }, @@ -307,6 +326,7 @@ describe("renderAnswer golden text", () => { "answer_only multiple fields", { answer_only: true, + basis: "Counted all orders at order grain using orders.id and summed orders.amount for revenue.", fields: [ { name: "Orders", value: 3 }, { name: "Revenue", value: 12, units: "USD" }, @@ -375,19 +395,14 @@ describe("renderAnswer golden text", () => { { basis: recordBasis, rows: { columns: ["A\r\nB\rC\nD"], values: [["a\r\nb\rc\nd"]] } }, `${recordBasis}\n\n**Answer:**\n| A B C D |\n|---|\n| a b c d |`, ], - [ - "answer_only unresolved without basis", - { answer_only: true, unresolved: { reason: "No matching rows." } }, - "unable to determine — No matching rows.", - ], [ "unresolved with basis", { basis: "Checked the requested period.", unresolved: { reason: "No matching rows." } }, "Checked the requested period.\n\n**Answer:** unable to determine — No matching rows.", ], [ - "answer_only unresolved retains its status without a label or basis", - { answer_only: true, basis: "Checked the requested period.", unresolved: { reason: "No matching rows." } }, + "answer_only unresolved retains its status and hides the supplied basis", + { answer_only: true, basis: recordBasis, unresolved: { reason: "No matching rows." } }, "unable to determine — No matching rows.", ], [ @@ -419,16 +434,11 @@ describe("renderAnswer golden text", () => { fields: [], rows: { columns: ["Count"], values: [[2]], note: "" }, unresolved: {}, - basis: [], + basis: recordBasis, readings: "", }, "| Count |\n|---|\n| 2 |", ], - [ - "whitespace-only basis is absent with answer_only", - { answer_only: true, basis: " \t\r\n ", fields: [{ name: "", value: 2 }] }, - "2", - ], [ "whitespace-only units are absent", { basis: recordBasis, fields: [{ name: "", value: 2, units: " \t\r\n " }] }, @@ -464,12 +474,6 @@ describe("renderAnswer strict-schema null fillers", () => { ) }) - test("null basis is absent with answer_only", () => { - expect(AnswerCommit.renderAnswer({ answer_only: true, fields: [{ name: "Count", value: 2 }], basis: null })).toBe( - "2", - ) - }) - test.each(["readings", "answer_only"])("null %s is absent", (key) => { expect(AnswerCommit.renderAnswer({ basis: recordBasis, fields: [{ name: "Count", value: 2 }], [key]: null })).toBe( `${recordBasis}\n\n**Answer:** Count: 2`, @@ -537,7 +541,7 @@ describe("renderAnswer structural validation", () => { AnswerCommit.renderAnswer({ answer_only: true, fields: [{ name: "", value: 2, precision: [], units: [] }], - basis: [], + basis: recordBasis, }), ).toBe("2") for (const filler of [[null], [0], [""], [{}], [[]]]) { @@ -585,21 +589,25 @@ describe("renderAnswer structural validation", () => { ["excessive precision", { fields: [{ name: "", value: 1.5, precision: 11 }] }, /precision/i], ["fractional precision", { fields: [{ name: "", value: 1.5, precision: 1.5 }] }, /precision/i], ["empty reason", { unresolved: { reason: "" } }, /unresolved.*reason/i], - ["no answer shape", {}, /exactly one of fields, rows, or unresolved/i], - ["only fillers", { fields: [], rows: {}, unresolved: "" }, /exactly one of fields, rows, or unresolved/i], + ["no answer shape", { basis: recordBasis }, /exactly one of fields, rows, or unresolved/i], + [ + "only fillers", + { basis: recordBasis, fields: [], rows: {}, unresolved: "" }, + /exactly one of fields, rows, or unresolved/i, + ], [ "fields and rows", - { fields: [{ name: "", value: 1 }], rows: { columns: ["A"], values: [[1]] } }, + { basis: recordBasis, fields: [{ name: "", value: 1 }], rows: { columns: ["A"], values: [[1]] } }, /exactly one of fields, rows, or unresolved/i, ], [ "fields and unresolved", - { fields: [{ name: "", value: 1 }], unresolved: { reason: "Missing" } }, + { basis: recordBasis, fields: [{ name: "", value: 1 }], unresolved: { reason: "Missing" } }, /exactly one of fields, rows, or unresolved/i, ], [ "rows and unresolved", - { rows: { columns: ["A"], values: [[1]] }, unresolved: { reason: "Missing" } }, + { basis: recordBasis, rows: { columns: ["A"], values: [[1]] }, unresolved: { reason: "Missing" } }, /exactly one of fields, rows, or unresolved/i, ], ] as [string, unknown, RegExp][])("rejects %s", (_name, payload, error) => { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index d68a523b46..ee45bc4d4d 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1018,8 +1018,16 @@ for (const mode of [ mode === "failed" ? {} : compacts - ? { rows: { columns: ["value"], values: rows }, answer_only: true } - : { fields: [{ name: "", value: 42 }], answer_only: true } + ? { + rows: { columns: ["value"], values: rows }, + answer_only: true, + basis: "Selected all records at record grain using records.value.", + } + : { + fields: [{ name: "", value: 42 }], + answer_only: true, + basis: "Counted all records at record grain using records.id.", + } const language = createOpenAICompatible({ name: "test", baseURL: "https://unused.invalid/v1", From e624ea603a1815e4db4a0bd599ef9beb5c1a8ff3 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 16:22:01 -0700 Subject: [PATCH 11/39] fix(analyst): echo after every commit, keep `StructuredOutput` requests tool-free, cap and type-check payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Structured-output requests (`format.type === "json_schema"`) no longer register `answer_commit`, never enter echo mode, and keep earlier commit tool parts as masked text so a historical stub cannot re-advertise the tool; the analyst prompt says `StructuredOutput` takes precedence for those requests. - Echo mode now starts right after any completed `answer_commit` for the current request, not only at the step limit or after compaction: the next generation is tool-free and carries the exact echo instruction. A failed commit keeps the tools for repair; a new user message resets finalization. - Rendered-size cap: `MAX_RENDERED_ANSWER_CHARACTERS` (60,000) applies to the complete rendered text of fields, rows and unresolved payloads; the error and the tool description tell the model to aggregate, narrow, or write the full table to a file and commit the summary. - Numeric precision: numbers with more than 15 significant digits or outside the safe-integer range are rejected with instructions to pass the exact value as a string (JSON parsing already changed `1234567890123456.7` to `…456.8`); strings are unchanged. - Regression tests for each: schema-mode tool list and masked history, echo after commit with unbounded steps and with a sibling tool, failed-commit repair, reset on a new user message, cap boundaries (59,999 / 60,000 / over), escaped cells and basis counted, and the parsed precision-loss literals plus safe-number controls. Co-Authored-By: Claude Fable 5.1 --- .../opencode/src/altimate/prompts/analyst.txt | 2 + .../src/altimate/tools/answer-commit.ts | 27 +- packages/opencode/src/session/prompt.ts | 56 +++-- .../opencode/test/agent/answer-commit.test.ts | 235 +++++++++++++++++- .../test/altimate/tools/answer-commit.test.ts | 92 ++++++- packages/opencode/test/session/prompt.test.ts | 10 +- 6 files changed, 389 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index 4f422bba7a..79416ad9fb 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -18,4 +18,6 @@ You are a trainable AI teammate. Check the "Teammate Training" section in your s If the user corrects your behavior, offer to save it as a rule using `training_save`. Use `training_list` to review learned knowledge. Skills: /teach, /train, /training-status. +When a JSON schema is requested, finish with `StructuredOutput` instead of `answer_commit` or a plain-text final reply; follow the requested schema. The following text finalization rules apply only when no JSON schema is requested. + Finish by calling `answer_commit` with the single chosen value(s), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Always supply `basis`, even when unresolved; it is never shown when the request asks for only the answer. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 3a79c902cf..1a202a0765 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -3,6 +3,11 @@ import { Effect, Option, Schema, SchemaIssue } from "effect" import type { JSONSchema7 } from "@ai-sdk/provider" import { Tool } from "../../tool/tool" +/** Maximum rendered answer length, including Markdown and basis, before the model echoes it. */ +export const MAX_RENDERED_ANSWER_CHARACTERS = 60_000 + +const renderedLengthGuidance = `Rendered answer text must not exceed ${MAX_RENDERED_ANSWER_CHARACTERS.toLocaleString("en-US")} characters; aggregate, narrow the result, or write the full table to a file and commit the summary.` + // Strict-schema callers fill unused optional properties with null, "", [] or {}. // Only optional properties accept these fillers; required values still validate. function optional(schema: T) { @@ -20,8 +25,11 @@ function optional(schema: T) { const number = z .number() .refine( - (value) => !Number.isInteger(value) || Number.isSafeInteger(value), - "Numeric integers must be safe integers; supply larger values as a string", + (value) => { + const digits = value.toExponential().split("e")[0]!.replace(/[-.]/g, "").length + return digits <= 15 && (!Number.isInteger(value) || Number.isSafeInteger(value)) + }, + "Numeric values must have at most 15 significant digits and integers must be safe; pass the exact value as a string", ) const field = z @@ -160,6 +168,11 @@ function tableCell(value: string | number | null): string { .replace(/\r\n|\r|\n/g, " ") } +function limitRenderedAnswer(text: string): string { + if (text.length > MAX_RENDERED_ANSWER_CHARACTERS) throw new Error(renderedLengthGuidance) + return text +} + /** Deterministically finalize a data-question answer without changing session state. */ export function renderAnswer(payload: unknown): string { const parsed = parser.safeParse(payload) @@ -179,7 +192,7 @@ export function renderAnswer(payload: unknown): string { return `${name}${value}${field.units ? ` ${field.units}` : ""}` }) .join("; ") - return args.answer_only ? values : `${basis}**Answer:** ${values}` + return limitRenderedAnswer(args.answer_only ? values : `${basis}**Answer:** ${values}`) } if (args.rows) { const row = (cells: (string | number | null)[]) => `| ${cells.map(tableCell).join(" | ")} |` @@ -188,10 +201,12 @@ export function renderAnswer(payload: unknown): string { `|${args.rows.columns.map(() => "---").join("|")}|`, ...args.rows.values.map(row), ].join("\n") - return args.answer_only ? table : `${basis}**Answer:**\n${table}${args.rows.note ? `\n${args.rows.note}` : ""}` + return limitRenderedAnswer( + args.answer_only ? table : `${basis}**Answer:**\n${table}${args.rows.note ? `\n${args.rows.note}` : ""}`, + ) } const unresolved = `unable to determine — ${args.unresolved!.reason}` - return args.answer_only ? unresolved : `${basis}**Answer:** ${unresolved}` + return limitRenderedAnswer(args.answer_only ? unresolved : `${basis}**Answer:** ${unresolved}`) } // Keep the renderer and native Effect tool on the same parser, including filler normalization. @@ -206,7 +221,7 @@ export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ description: - "Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Always supply `basis`; it is never shown when the request asks for only the answer. Supply one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in `basis` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. In JSON-schema mode, this tool is not automatically removed when a user output schema is set; the user schema takes precedence.", + `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, parameters: Parameters, formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)), // Preserve the existing wire schema; Zod and the SDK use different recursive JSON Schema types. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f755bf3006..53b8c3e02c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -817,7 +817,7 @@ export namespace SessionPrompt { using _unsubToolsChanged = defer(unsubscribeToolsChanged) // altimate_change end // altimate_change start - retain the exact committed text through automatic compaction - let committedAnswer: { userID: MessageID; output: string; compacted?: true } | undefined + let committedAnswer: { userID: MessageID; output: string } | undefined const compactionContinuations = new Set() // altimate_change end while (true) { @@ -887,18 +887,19 @@ export namespace SessionPrompt { lastAssistant?.parentID === commitRequest.info.id && !lastAssistant.summary ) { - const lastTool = msgs - .find((msg) => msg.info.id === lastAssistant.id) - ?.parts.findLast((part) => part.type === "tool") + const parts = msgs.find((msg) => msg.info.id === lastAssistant.id)?.parts + const commit = parts?.findLast( + (part) => part.type === "tool" && part.tool === "answer_commit" && part.state.status === "completed", + ) // An echo that overflowed has no finish or tool part; keep its committed output for the retry. - if (lastTool || lastAssistant.finish) + if (parts?.some((part) => part.type === "tool") || lastAssistant.finish) committedAnswer = commitRequest.info.agent === "analyst" && (commitRequest.info.format?.type ?? "text") === "text" && lastAssistant.agent === "analyst" && - lastTool?.tool === "answer_commit" && - lastTool.state.status === "completed" - ? { userID: commitRequest.info.id, output: lastTool.state.output } + commit?.type === "tool" && + commit.state.status === "completed" + ? { userID: commitRequest.info.id, output: commit.state.output } : undefined } // altimate_change end @@ -1198,9 +1199,6 @@ export namespace SessionPrompt { // re-enters compaction on the same unresolved marker and busy-loops. if (result !== "continue") break // altimate_change end - // altimate_change start - compaction must not replace a committed answer with further analysis - if (task.auto && committedAnswer) committedAnswer.compacted = true - // altimate_change end continue } @@ -1509,6 +1507,9 @@ export namespace SessionPrompt { // Inject StructuredOutput tool if JSON schema mode enabled if (lastUser.format?.type === "json_schema") { + // altimate_change start - schema-mode requests finish through StructuredOutput, never answer_commit + delete tools.answer_commit + // altimate_change end tools["StructuredOutput"] = createStructuredOutputTool({ schema: lastUser.format.schema, onSuccess(output) { @@ -1723,12 +1724,9 @@ export namespace SessionPrompt { if (format.type === "json_schema") { system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) } - // altimate_change start - the final step echoes a successful commit instead of replacing it with a summary + // altimate_change start - every successful commit makes the next generation an echo, including after compaction const echoCommittedAnswer = - (isLastStep || committedAnswer?.compacted) && - format.type === "text" && - agent.name === "analyst" && - committedAnswer !== undefined + format.type === "text" && agent.name === "analyst" && committedAnswer !== undefined const committedOutputMissing = echoCommittedAnswer && !msgs.some((msg) => @@ -1777,6 +1775,28 @@ export namespace SessionPrompt { } // altimate_change end + // altimate_change start - keep past commits as context without letting historical tool stubs re-advertise them in schema mode + const modelHistory = + format.type === "json_schema" + ? msgs.map((msg) => ({ + ...msg, + parts: msg.parts.map((part): MessageV2.Part => { + if (part.type !== "tool" || part.tool !== "answer_commit") return part + let text = "answer_commit did not complete." + if (part.state.status === "error") text = part.state.error + if (part.state.status === "completed") { + const mask = part.state.metadata?.observation_mask + text = part.state.time.compacted + ? typeof mask === "string" && mask.length > 0 + ? mask + : "[Old tool result content cleared]" + : part.state.output + } + return { id: part.id, sessionID: part.sessionID, messageID: part.messageID, type: "text", text } + }), + })) + : msgs + // altimate_change end const result = await processor.process({ user: lastUser, // altimate_change start — correlate request-boundary snapshots with this assistant message @@ -1787,8 +1807,8 @@ export namespace SessionPrompt { sessionID, system, messages: [ - ...(await MessageV2.toModelMessages(msgs, model)), - // altimate_change start - a compacted commit uses the next normal generation even before the step limit + ...(await MessageV2.toModelMessages(modelHistory, model)), + // altimate_change start - a committed answer uses the next normal generation even before the step limit ...(isLastStep || echoCommittedAnswer ? [ { diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 69f2f690d4..311d18d0e8 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -1,4 +1,7 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import path from "node:path" import { ProjectV2 } from "@opencode-ai/core/project" import { Effect, Layer } from "effect" @@ -7,19 +10,52 @@ import { ToolLookupTool } from "../../src/altimate/tools/tool-lookup" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" -import type { Provider } from "../../src/provider/provider" +import { Provider } from "../../src/provider/provider" +import { Session } from "../../src/session/session" +import { Session as LegacySession } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionSummary } from "../../src/session/summary" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { ModelID, ProviderID } from "../../src/provider/schema" -import { MessageID, SessionID } from "../../src/session/schema" +import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionTools } from "../../src/session/tools" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { initTool } from "../altimate/tool-fixture" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { reply } from "../lib/llm-server" import { withLegacyInstance } from "../session/legacy-instance" const model = { providerID: ProviderID.opencode, modelID: ModelID.make("test") } const it = testEffect(Layer.mergeAll(Agent.defaultLayer, ToolRegistry.layer)) +const promptTests = testEffect( + LayerNode.buildLayer(LayerNode.group([SessionPrompt.node, Session.node, Agent.node, SessionProjector.node]), { + replacements: [ + LayerNode.replace( + MCP.node, + Layer.mock(MCP.Service, { + tools: () => Effect.succeed({}), + snapshot: () => Effect.succeed({ tools: {}, meta: undefined }), + listMeta: () => Effect.succeed(undefined), + entry: () => Effect.succeed(undefined), + }), + ), + LayerNode.replace( + SessionSummary.node, + Layer.mock(SessionSummary.Service, { + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), + ), + LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })), + ], + }), +) +const promptModel = { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model") } +const echoInstruction = "Tools are disabled. Reply with exactly the text returned by answer_commit and nothing else." + let previousToggle: string | undefined beforeEach(() => { @@ -309,3 +345,196 @@ describe("answer_commit agent scope", () => { ), ) }) + +for (const scenario of ["text", "json_schema", "json_schema-history", "new-user", "failed", "sibling-tool"] as const) { + const structured = scenario.startsWith("json_schema") + const priorCommit = scenario === "json_schema-history" + const name = { + text: "text requests offer answer_commit and immediately echo a successful commit without a step limit", + json_schema: "json_schema requests omit answer_commit and never enter echo mode", + "json_schema-history": "json_schema requests preserve masked commit history without re-advertising answer_commit", + "new-user": "a new user message resets a successful commit before the next step", + failed: "a failed commit leaves tools available for repair", + "sibling-tool": "a successful commit enters echo mode even when a sibling tool follows it", + }[scenario] + promptTests.instance( + name, + () => + withLegacyInstance( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const agents = yield* Agent.Service + expect((yield* agents.get("analyst")).steps).toBeUndefined() + const chat = yield* sessions.create({ title: "Answer commit finalization" }) + const requests: { tools?: { function: { name: string } }[]; messages: unknown[]; tool_choice?: string }[] = [] + const args = { + fields: [{ name: "", value: 42 }], + answer_only: true, + basis: "Counted all records at record grain using records.id.", + } + const language = createOpenAICompatible({ + name: "test", + baseURL: "https://unused.invalid/v1", + fetch: Object.assign( + async (_url: string | URL | Request, init?: RequestInit) => { + if (typeof init?.body !== "string") throw new Error("expected a JSON request body") + requests.push(JSON.parse(init.body)) + if (requests.length > (priorCommit ? 4 : 2)) throw new Error("unexpected extra generation") + const step = priorCommit && requests.length > 2 ? requests.length - 2 : requests.length + if (scenario === "new-user" && requests.length === 1) { + const userID = MessageID.ascending() + await LegacySession.updateMessage({ + id: userID, + sessionID: chat.id, + role: "user", + agent: "analyst", + model: promptModel, + time: { created: Date.now() }, + }) + await LegacySession.updatePart({ + id: PartID.ascending(), + messageID: userID, + sessionID: chat.id, + type: "text", + text: "A new question: how many sources?", + }) + } + const response = + step === 1 + ? // The structured case deliberately attempts a stale commit call to verify it cannot activate echo mode. + scenario === "sibling-tool" + ? reply().tool("answer_commit", args).tool("tool_lookup", { tool_name: "__missing_tool" }).item() + : reply() + .tool("answer_commit", scenario === "failed" ? {} : args) + .item() + : structured && (!priorCommit || requests.length > 2) + ? reply().tool("StructuredOutput", { count: 42 }).item() + : reply() + .text(scenario === "new-user" ? "3" : "42") + .stop() + .item() + if (response.type !== "sse") throw new Error("expected SSE response") + return new Response( + [...response.head, ...response.tail].map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + + "data: [DONE]\n\n", + { headers: { "Content-Type": "text/event-stream" } }, + ) + }, + { preconnect: () => undefined }, + ), + }).chatModel("test-model") + const languageSpy = spyOn(Provider, "getLanguage").mockResolvedValue(language) + try { + if (priorCommit) { + yield* prompt.prompt({ + sessionID: chat.id, + agent: "analyst", + model: promptModel, + parts: [{ type: "text", text: "How many records? Reply with only the count." }], + }) + const history = yield* sessions.messages({ sessionID: chat.id }) + const commit = history + .flatMap((message) => message.parts) + .find((part) => part.type === "tool" && part.tool === "answer_commit") + if (commit?.type !== "tool" || commit.state.status !== "completed") + throw new Error("missing prior commit") + const state = commit.state + yield* Effect.promise(() => + LegacySession.updatePart({ + ...commit, + state: { + ...state, + output: "UNMASKED_COMMIT_OUTPUT", + metadata: { ...state.metadata, observation_mask: "Previous committed answer was masked." }, + time: { ...state.time, compacted: Date.now() }, + }, + }), + ) + } + const result = yield* prompt.prompt({ + sessionID: chat.id, + agent: "analyst", + model: promptModel, + parts: [{ type: "text", text: "How many records? Reply with only the count." }], + ...(structured + ? { + format: { + type: "json_schema" as const, + retryCount: 0, + schema: { + type: "object", + properties: { count: { type: "number" } }, + required: ["count"], + additionalProperties: false, + }, + }, + } + : {}), + }) + expect(result.info).not.toHaveProperty("error") + expect(requests).toHaveLength(priorCommit ? 4 : 2) + const tools = (index: number) => (requests[index].tools ?? []).map((tool) => tool.function.name) + if (structured) { + for (const request of priorCommit ? requests.slice(2) : requests) { + expect((request.tools ?? []).map((tool) => tool.function.name)).not.toContain("answer_commit") + expect((request.tools ?? []).map((tool) => tool.function.name)).toContain("StructuredOutput") + expect(request.tool_choice).toBe("required") + expect(JSON.stringify(request.messages)).not.toContain(echoInstruction) + if (priorCommit) { + expect(JSON.stringify(request.messages)).toContain("Previous committed answer was masked.") + expect(JSON.stringify(request.messages)).not.toContain("UNMASKED_COMMIT_OUTPUT") + } + } + expect(result.info).toHaveProperty("structured", { count: 42 }) + } else { + expect(tools(0)).toContain("answer_commit") + const messages = yield* sessions.messages({ sessionID: chat.id }) + const commit = messages + .flatMap((message) => message.parts) + .find((part) => part.type === "tool" && part.tool === "answer_commit") + expect(commit?.type === "tool" && commit.state.status).toBe(scenario === "failed" ? "error" : "completed") + if (scenario === "text" || scenario === "sibling-tool") { + expect(tools(1)).toEqual([]) + expect(JSON.stringify(requests[1].messages)).toContain(echoInstruction) + expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: "42" })) + } else { + expect(tools(1)).toContain("answer_commit") + expect(JSON.stringify(requests[1].messages)).not.toContain(echoInstruction) + } + } + } finally { + languageSpy.mockRestore() + } + }), + ), + { + config: { + snapshot: false, + learn: { enabled: false }, + agent: { analyst: { permission: { StructuredOutput: "allow" } } }, + provider: { + test: { + name: "Test", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + "test-model": { + name: "Test Model", + tool_call: true, + limit: { context: 100000, output: 10000 }, + }, + }, + options: { apiKey: "test-key", baseURL: "https://unused.invalid/v1" }, + }, + }, + }, + }, + 30_000, + ) +} + +test("analyst prompt gives StructuredOutput precedence over answer_commit", async () => { + const prompt = await Bun.file(new URL("../../src/altimate/prompts/analyst.txt", import.meta.url)).text() + expect(prompt).toContain("When a JSON schema is requested, finish with `StructuredOutput` instead") +}) diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index c6bbcd8216..e15923a749 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -191,7 +191,36 @@ describe("answer_commit numeric safety", () => { await rejects(tool.execute(payload, context), /rows.*values.*safe.*string/i) }) - test.each([Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER, 1.25, "9007199254740993", "-9007199254740993"])( + test.each(["1234567890123456.7", "9007199254740991.1", "-1234567890123456.7", "0.1234567890123456"])( + "rejects precision-losing parsed number %s in fields and rows", + async (literal) => { + const value = JSON.parse(literal) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + for (const shape of [ + { fields: [{ name: "", value }] }, + { rows: { columns: ["Value"], values: [[value]] } }, + ]) { + const payload = { basis: recordBasis, ...shape } + const error = /15 significant digits.*exact value as a string/i + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(error) + await rejects(tool.execute(payload, context), error) + } + }, + ) + + test.each([ + 999999999999999, + -999999999999999, + 46919.15, + 12, + 0.1, + -3.5e-7, + 1.25, + "9007199254740993", + "-9007199254740993", + "1234567890123456.7", + "9007199254740991.1", + ])( "preserves safe numbers and numeric strings: %s", async (value) => { const tool = await initTool(AnswerCommit.AnswerCommitTool) @@ -207,6 +236,62 @@ describe("answer_commit numeric safety", () => { ) }) +describe("answer_commit rendered length limit", () => { + const cap = 60_000 + const error = /60,000 characters.*aggregate.*narrow.*write the full table to a file.*commit the summary/i + + test("rejects rows whose rendered text exceeds the cap", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + const payload = { + basis: recordBasis, + answer_only: true, + rows: { columns: ["Value"], values: [["x".repeat(cap)]] }, + } + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(error) + await rejects(tool.execute(payload, context), error) + }) + + test.each([cap - 1, cap])("accepts rows with %i rendered characters", async (length) => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + const overhead = "| Value |\n|---|\n| |".length + const payload = { + basis: recordBasis, + answer_only: true, + rows: { columns: ["Value"], values: [["x".repeat(length - overhead)]] }, + } + const rendered = AnswerCommit.renderAnswer(payload) + expect(rendered.length).toBe(length) + expect((await tool.execute(payload, context)).output).toBe(rendered) + }) + + test("counts escaped table cells and rendered basis toward the cap", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + const payload = { + basis: recordBasis, + rows: { columns: ["Value"], values: [["|".repeat(cap / 2)]] }, + } + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(error) + await rejects(tool.execute(payload, context), error) + }) + + test("applies the rendered cap to fields and unresolved answers", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + for (const shape of [ + { fields: [{ name: "", value: "x".repeat(cap) }] }, + { unresolved: { reason: "x".repeat(cap) } }, + ]) { + const payload = { basis: recordBasis, ...shape } + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(error) + await rejects(tool.execute(payload, context), error) + } + }) + + test("documents the rendered character cap and recovery in the tool description", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect(tool.description).toMatch(error) + }) +}) + describe("renderAnswer golden text", () => { const cases: [string, unknown, string][] = [ [ @@ -694,10 +779,9 @@ test("answer_commit rows schema documents the requested count and boundary-tie n }) }) -test("answer_commit description gives the user output schema precedence", async () => { +test("answer_commit description excludes JSON-schema requests", async () => { const tool = await initTool(AnswerCommit.AnswerCommitTool) - expect(tool.description).toContain("not automatically removed when a user output schema is set") - expect(tool.description).toContain("the user schema takes precedence") + expect(tool.description).toContain("not available for JSON-schema requests; use StructuredOutput instead") }) test("answer_commit accepts null optional fields through the tool wrapper", async () => { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index ee45bc4d4d..00280d91c8 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1066,7 +1066,10 @@ for (const mode of [ const response = requests.length === 1 ? reply() - .tool("answer_commit", args) + .tool( + mode === "structured" ? "tool_lookup" : "answer_commit", + mode === "structured" ? { tool_name: "__missing_tool" } : args, + ) .usage({ input: mode === "compacted-usage" ? 80_000 : compacts ? 70_000 : 1, output: 1 }) .item() : compacts && (requests.length === 2 || (mode === "compacted-retry" && requests.length === 4)) @@ -1115,7 +1118,7 @@ for (const mode of [ const commit = messages .flatMap((message) => message.parts) .find((part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "answer_commit") - expect(commit?.state.status).toBe(mode === "failed" ? "error" : "completed") + expect(commit?.state.status).toBe(mode === "structured" ? undefined : mode === "failed" ? "error" : "completed") if (mode === "committed" || (compacts && !newRequest)) { const finalRequest = requests.at(-1)! expect(JSON.stringify(finalRequest.messages)).not.toContain("CRITICAL - MAXIMUM STEPS REACHED") @@ -1140,6 +1143,9 @@ for (const mode of [ expect.objectContaining({ type: "text", text: "Maximum steps reached." }), ) } else { + for (const request of requests) { + expect(JSON.stringify(request.tools)).not.toContain('"name":"answer_commit"') + } expect(requests[1].tool_choice).toBe("required") expect(result.info).toHaveProperty("structured", { count: 42 }) } From c2f62d8e70827a1e404890d0486052088c3f7c93 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 16:30:55 -0700 Subject: [PATCH 12/39] fix(analyst): send the commit echo instruction as a user turn Bedrock's Converse API rejects a request whose last message has the assistant role ("conversation must end with a user message", HTTP 400), so the tool-free echo step after `answer_commit` failed live and the run ended with an error instead of the committed text. The echo instruction is now the final user turn, which every provider accepts; the upstream step-limit message is unchanged. Found by the live Bedrock smoke session, not by the mocked-provider tests. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/prompt.ts | 22 ++++++++++++------- .../opencode/test/agent/answer-commit.test.ts | 5 +++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 53b8c3e02c..f996a15286 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1809,18 +1809,24 @@ export namespace SessionPrompt { messages: [ ...(await MessageV2.toModelMessages(modelHistory, model)), // altimate_change start - a committed answer uses the next normal generation even before the step limit - ...(isLastStep || echoCommittedAnswer + ...(echoCommittedAnswer ? [ { - role: "assistant" as const, - // altimate_change start - preserve the committed answer within the existing final generation - content: echoCommittedAnswer - ? `Tools are disabled. Reply with exactly the text returned by answer_commit and nothing else.${committedOutputMissing ? `\n\n${committedAnswer!.output}` : ""}` - : MAX_STEPS, - // altimate_change end + // A user turn: Bedrock's Converse API rejects a conversation that ends with an + // assistant message ("conversation must end with a user message"), and every + // provider accepts an instruction as the final user turn. + role: "user" as const, + content: `Tools are disabled. Reply with exactly the text returned by answer_commit and nothing else.${committedOutputMissing ? `\n\n${committedAnswer!.output}` : ""}`, }, ] - : []), + : isLastStep + ? [ + { + role: "assistant" as const, + content: MAX_STEPS, + }, + ] + : []), // altimate_change end ], // altimate_change start - the final echo cannot spend another step on tools diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 311d18d0e8..df4a8b3f04 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -497,6 +497,11 @@ for (const scenario of ["text", "json_schema", "json_schema-history", "new-user" if (scenario === "text" || scenario === "sibling-tool") { expect(tools(1)).toEqual([]) expect(JSON.stringify(requests[1].messages)).toContain(echoInstruction) + // The echo instruction must be the final user turn: Bedrock's Converse API rejects + // a conversation that ends with an assistant message. + const last = requests[1].messages.at(-1) as { role?: string; content?: unknown } + expect(last.role).toBe("user") + expect(JSON.stringify(last.content)).toContain(echoInstruction) expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: "42" })) } else { expect(tools(1)).toContain("answer_commit") From cc3dc50564bd80ee167fa75650cdac720ca0617c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 17:25:05 -0700 Subject: [PATCH 13/39] fix(analyst): make the commit echo robust to output limits, queued turns and failed retries - A finished echo whose text hits the model's output limit, or differs from the stored committed output, is replaced with that output verbatim and finishes with `stop`; a correct echo is left untouched and an input overflow still takes the compaction retry. - Immediately before an echo is sent, persisted messages are re-read: a user request that arrived after the iteration's snapshot clears the commit, drops the unused assistant message and restarts the loop with tools, so a new question is never answered with the previous question's echo. - Re-detection looks at the last `answer_commit` attempt of any status; only a completed last attempt enters echo mode, so a success followed by a failed retry keeps the tools available for repair. - Prompt: the `StructuredOutput` precedence sentence now applies only when the request supplies an output JSON schema, not to text questions about schemas. - Tool description states that numbers are accepted up to 15 significant digits and that decimals which must be reproduced exactly are passed as strings. - Regression tests for the length-limited and mismatched echo, exact echo with a `length` finish, queued user turn before and during the echo, success-then- failure commit pair, prompt wording, and the description sentence. Co-Authored-By: Claude Fable 5.1 --- .../opencode/src/altimate/prompts/analyst.txt | 2 +- .../src/altimate/tools/answer-commit.ts | 2 +- packages/opencode/src/session/prompt.ts | 66 ++++++- .../opencode/test/agent/answer-commit.test.ts | 168 +++++++++++++++--- .../test/altimate/tools/answer-commit.test.ts | 7 + 5 files changed, 210 insertions(+), 35 deletions(-) diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index 79416ad9fb..02a1cc1825 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -18,6 +18,6 @@ You are a trainable AI teammate. Check the "Teammate Training" section in your s If the user corrects your behavior, offer to save it as a rule using `training_save`. Use `training_list` to review learned knowledge. Skills: /teach, /train, /training-status. -When a JSON schema is requested, finish with `StructuredOutput` instead of `answer_commit` or a plain-text final reply; follow the requested schema. The following text finalization rules apply only when no JSON schema is requested. +When the request supplies an output JSON schema (the `StructuredOutput` tool is available), finish with `StructuredOutput` instead of `answer_commit` or a plain-text reply; follow that schema. The text finalization rules below apply when no output schema is supplied. Finish by calling `answer_commit` with the single chosen value(s), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Always supply `basis`, even when unresolved; it is never shown when the request asks for only the answer. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 1a202a0765..831c5649c5 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -221,7 +221,7 @@ export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ description: - `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, + `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Numbers are accepted up to 15 significant digits; any decimal that must be reproduced exactly (more digits, trailing zeros, or currency strings) must be passed as a string. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, parameters: Parameters, formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)), // Preserve the existing wire schema; Zod and the SDK use different recursive JSON Schema types. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f996a15286..0d122588ab 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -888,9 +888,7 @@ export namespace SessionPrompt { !lastAssistant.summary ) { const parts = msgs.find((msg) => msg.info.id === lastAssistant.id)?.parts - const commit = parts?.findLast( - (part) => part.type === "tool" && part.tool === "answer_commit" && part.state.status === "completed", - ) + const commit = parts?.findLast((part) => part.type === "tool" && part.tool === "answer_commit") // An echo that overflowed has no finish or tool part; keep its committed output for the retry. if (parts?.some((part) => part.type === "tool") || lastAssistant.finish) committedAnswer = @@ -1797,6 +1795,28 @@ export namespace SessionPrompt { })) : msgs // altimate_change end + // altimate_change start - recheck persisted requests after asynchronous history conversion, before sending an echo + const modelMessages = await MessageV2.toModelMessages(modelHistory, model) + if (echoCommittedAnswer && committedAnswer) { + let newerUserRequest = false + for (const msg of MessageV2.stream(sessionID)) { + if (msg.info.id <= committedAnswer.userID) break + if ( + msg.info.role === "user" && + !msg.parts.some((part) => part.type === "compaction") && + !compactionContinuations.has(msg.info.id) + ) { + newerUserRequest = true + break + } + } + if (newerUserRequest) { + committedAnswer = undefined + await Session.removeMessage({ sessionID, messageID: processor.message.id }) + continue + } + } + // altimate_change end const result = await processor.process({ user: lastUser, // altimate_change start — correlate request-boundary snapshots with this assistant message @@ -1807,7 +1827,9 @@ export namespace SessionPrompt { sessionID, system, messages: [ - ...(await MessageV2.toModelMessages(modelHistory, model)), + // altimate_change start - use the history converted before the fresh-user check + ...modelMessages, + // altimate_change end // altimate_change start - a committed answer uses the next normal generation even before the step limit ...(echoCommittedAnswer ? [ @@ -1838,6 +1860,39 @@ export namespace SessionPrompt { // altimate_change end }) + // altimate_change start - finished echoes must preserve the committed output even at the model's output limit + const echoFinished = + echoCommittedAnswer && + !!processor.message.finish && + !["tool-calls", "unknown"].includes(processor.message.finish) && + !processor.message.error && + !abort.aborted + if (echoFinished && committedAnswer) { + const textParts = MessageV2.parts(processor.message.id).filter((part) => part.type === "text") + if ( + processor.message.finish === "length" || + textParts.map((part) => part.text).join("") !== committedAnswer.output + ) { + await Session.updatePart({ + ...(textParts[0] ?? { + id: PartID.ascending(), + sessionID, + messageID: processor.message.id, + type: "text" as const, + }), + text: committedAnswer.output, + }) + for (const part of textParts.slice(1)) { + await Session.removePart({ sessionID, messageID: processor.message.id, partID: part.id }) + } + } + if (processor.message.finish !== "stop") { + processor.message.finish = "stop" + await Session.updateMessage(processor.message) + } + } + // An input overflow has no finish, so it still follows the existing compaction retry below. + // altimate_change end // If structured output was captured, save it and exit immediately // This takes priority because the StructuredOutput tool was called successfully if (structuredOutput !== undefined) { @@ -2159,6 +2214,9 @@ export namespace SessionPrompt { } // altimate_change end + // altimate_change start - use the normal stop check for finished echoes, preserving any newly queued request + if (echoFinished) continue + // altimate_change end if (result === "stop") break if (result === "compact") { // altimate_change start — track compaction count diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index df4a8b3f04..dc797dc015 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -14,6 +14,7 @@ import { Provider } from "../../src/provider/provider" import { Session } from "../../src/session/session" import { Session as LegacySession } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" +import { MessageV2 } from "../../src/session/message-v2" import { SessionSummary } from "../../src/session/summary" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -346,15 +347,34 @@ describe("answer_commit agent scope", () => { ) }) -for (const scenario of ["text", "json_schema", "json_schema-history", "new-user", "failed", "sibling-tool"] as const) { +for (const scenario of [ + "text", + "echo-length", + "echo-mismatch", + "echo-exact-length", + "json_schema", + "json_schema-history", + "new-user", + "queued-user", + "user-during-echo", + "failed", + "failed-after-success", + "sibling-tool", +] as const) { const structured = scenario.startsWith("json_schema") const priorCommit = scenario === "json_schema-history" const name = { text: "text requests offer answer_commit and immediately echo a successful commit without a step limit", + "echo-length": "a length-limited echo is replaced with the stored committed output verbatim", + "echo-mismatch": "a mismatched echo is replaced with the stored committed output verbatim", + "echo-exact-length": "an exact echo with a length finish ends normally with stop", json_schema: "json_schema requests omit answer_commit and never enter echo mode", "json_schema-history": "json_schema requests preserve masked commit history without re-advertising answer_commit", "new-user": "a new user message resets a successful commit before the next step", + "queued-user": "a user message persisted after the snapshot keeps tools enabled and prevents stale echo", + "user-during-echo": "a user message persisted during echo generation is answered on the next step", failed: "a failed commit leaves tools available for repair", + "failed-after-success": "a successful commit followed by validation failure leaves tools available for repair", "sibling-tool": "a successful commit enters echo mode even when a sibling tool follows it", }[scenario] promptTests.instance( @@ -373,6 +393,26 @@ for (const scenario of ["text", "json_schema", "json_schema-history", "new-user" answer_only: true, basis: "Counted all records at record grain using records.id.", } + let queuedUserID: MessageID | undefined + const insertUser = async () => { + const userID = MessageID.ascending() + await LegacySession.updateMessage({ + id: userID, + sessionID: chat.id, + role: "user", + agent: "analyst", + model: promptModel, + time: { created: Date.now() }, + }) + await LegacySession.updatePart({ + id: PartID.ascending(), + messageID: userID, + sessionID: chat.id, + type: "text", + text: "A new question: how many sources?", + }) + queuedUserID = userID + } const language = createOpenAICompatible({ name: "test", baseURL: "https://unused.invalid/v1", @@ -380,44 +420,66 @@ for (const scenario of ["text", "json_schema", "json_schema-history", "new-user" async (_url: string | URL | Request, init?: RequestInit) => { if (typeof init?.body !== "string") throw new Error("expected a JSON request body") requests.push(JSON.parse(init.body)) - if (requests.length > (priorCommit ? 4 : 2)) throw new Error("unexpected extra generation") + if (requests.length > (priorCommit ? 4 : scenario === "user-during-echo" ? 3 : 2)) + throw new Error("unexpected extra generation") const step = priorCommit && requests.length > 2 ? requests.length - 2 : requests.length - if (scenario === "new-user" && requests.length === 1) { - const userID = MessageID.ascending() - await LegacySession.updateMessage({ - id: userID, - sessionID: chat.id, - role: "user", - agent: "analyst", - model: promptModel, - time: { created: Date.now() }, - }) - await LegacySession.updatePart({ - id: PartID.ascending(), - messageID: userID, - sessionID: chat.id, - type: "text", - text: "A new question: how many sources?", - }) + if ( + (scenario === "new-user" && requests.length === 1) || + (scenario === "user-during-echo" && requests.length === 2) + ) { + await insertUser() } const response = step === 1 ? // The structured case deliberately attempts a stale commit call to verify it cannot activate echo mode. scenario === "sibling-tool" ? reply().tool("answer_commit", args).tool("tool_lookup", { tool_name: "__missing_tool" }).item() - : reply() - .tool("answer_commit", scenario === "failed" ? {} : args) - .item() + : scenario === "failed-after-success" + ? reply().tool("answer_commit", args).tool("answer_commit", {}).item() + : reply() + .tool("answer_commit", scenario === "failed" ? {} : args) + .item() : structured && (!priorCommit || requests.length > 2) ? reply().tool("StructuredOutput", { count: 42 }).item() : reply() - .text(scenario === "new-user" ? "3" : "42") + .text( + scenario === "new-user" || scenario === "queued-user" || step === 3 + ? "3" + : scenario === "echo-length" + ? "4" + : scenario === "echo-mismatch" + ? "The answer is 42." + : "42", + ) .stop() .item() if (response.type !== "sse") throw new Error("expected SSE response") + const chunks = [...response.head, ...response.tail] + if (step === 1 && scenario === "failed-after-success") { + // OpenAI-compatible streams identify distinct calls by index, including calls to the same tool. + let index = -1 + for (const chunk of chunks) { + const event = chunk as { + choices?: { delta?: { tool_calls?: { id?: string; index: number }[] } }[] + } + for (const choice of event.choices ?? []) { + for (const call of choice.delta?.tool_calls ?? []) { + if (call.id) index++ + call.index = index + } + } + } + } + if (step === 2 && (scenario === "echo-length" || scenario === "echo-exact-length")) { + for (const chunk of chunks) { + const event = chunk as { choices?: { finish_reason?: string }[] } + for (const choice of event.choices ?? []) { + if (choice.finish_reason === "stop") choice.finish_reason = "length" + } + } + } return new Response( - [...response.head, ...response.tail].map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + - "data: [DONE]\n\n", + chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "Content-Type": "text/event-stream" } }, ) }, @@ -425,6 +487,13 @@ for (const scenario of ["text", "json_schema", "json_schema-history", "new-user" ), }).chatModel("test-model") const languageSpy = spyOn(Provider, "getLanguage").mockResolvedValue(language) + const toModelMessages = MessageV2.toModelMessages + const historySpy = spyOn(MessageV2, "toModelMessages").mockImplementation(async (...input) => { + const messages = await toModelMessages(...input) + if (scenario === "queued-user" && requests.length === 1 && !queuedUserID) await insertUser() + return messages + }) + const partSpy = spyOn(LegacySession, "updatePart") try { if (priorCommit) { yield* prompt.prompt({ @@ -473,7 +542,7 @@ for (const scenario of ["text", "json_schema", "json_schema-history", "new-user" : {}), }) expect(result.info).not.toHaveProperty("error") - expect(requests).toHaveLength(priorCommit ? 4 : 2) + expect(requests).toHaveLength(priorCommit ? 4 : scenario === "user-during-echo" ? 3 : 2) const tools = (index: number) => (requests[index].tools ?? []).map((tool) => tool.function.name) if (structured) { for (const request of priorCommit ? requests.slice(2) : requests) { @@ -494,7 +563,16 @@ for (const scenario of ["text", "json_schema", "json_schema-history", "new-user" .flatMap((message) => message.parts) .find((part) => part.type === "tool" && part.tool === "answer_commit") expect(commit?.type === "tool" && commit.state.status).toBe(scenario === "failed" ? "error" : "completed") - if (scenario === "text" || scenario === "sibling-tool") { + if (scenario === "failed-after-success") { + expect( + messages + .flatMap((message) => message.parts) + .filter((part) => part.type === "tool" && part.tool === "answer_commit") + .map((part) => part.type === "tool" && part.state.status), + ).toEqual(["completed", "error"]) + } + if (scenario === "text" || scenario === "sibling-tool" || scenario.startsWith("echo-")) { + if (commit?.type !== "tool" || commit.state.status !== "completed") throw new Error("missing commit") expect(tools(1)).toEqual([]) expect(JSON.stringify(requests[1].messages)).toContain(echoInstruction) // The echo instruction must be the final user turn: Bedrock's Converse API rejects @@ -503,12 +581,41 @@ for (const scenario of ["text", "json_schema", "json_schema-history", "new-user" expect(last.role).toBe("user") expect(JSON.stringify(last.content)).toContain(echoInstruction) expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: "42" })) + expect(result.info).toHaveProperty("finish", "stop") + expect( + result.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""), + ).toBe(commit.state.output) + if (scenario === "text") { + // A correct echo needs only the stream's text-start and text-end writes, with no replacement. + expect( + partSpy.mock.calls.filter(([part]) => part.messageID === result.info.id && part.type === "text"), + ).toHaveLength(2) + } + } else if (scenario === "user-during-echo") { + expect(tools(1)).toEqual([]) + expect(JSON.stringify(requests[1].messages)).toContain(echoInstruction) + expect(tools(2)).toContain("answer_commit") + expect(JSON.stringify(requests[2].messages)).not.toContain(echoInstruction) + expect(JSON.stringify(requests[2].messages)).toContain("A new question: how many sources?") + expect(result.info).toHaveProperty("parentID", queuedUserID) + expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: "3" })) } else { expect(tools(1)).toContain("answer_commit") expect(JSON.stringify(requests[1].messages)).not.toContain(echoInstruction) + if (scenario === "queued-user") { + expect(queuedUserID).toBeDefined() + expect(JSON.stringify(requests[1].messages)).toContain("A new question: how many sources?") + expect(result.info).toHaveProperty("parentID", queuedUserID) + expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: "3" })) + } } } } finally { + partSpy.mockRestore() + historySpy.mockRestore() languageSpy.mockRestore() } }), @@ -539,7 +646,10 @@ for (const scenario of ["text", "json_schema", "json_schema-history", "new-user" ) } -test("analyst prompt gives StructuredOutput precedence over answer_commit", async () => { +test("analyst prompt gives StructuredOutput precedence only when an output JSON schema is supplied", async () => { const prompt = await Bun.file(new URL("../../src/altimate/prompts/analyst.txt", import.meta.url)).text() - expect(prompt).toContain("When a JSON schema is requested, finish with `StructuredOutput` instead") + expect(prompt).toContain( + "When the request supplies an output JSON schema (the `StructuredOutput` tool is available), finish with `StructuredOutput` instead of `answer_commit` or a plain-text reply; follow that schema. The text finalization rules below apply when no output schema is supplied.", + ) + expect(prompt).not.toContain("When a JSON schema is requested") }) diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index e15923a749..9b721d12fa 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -177,6 +177,13 @@ test("answer_commit executes synchronously with native Effect permissions", asyn const recordBasis = "Used all records at record grain from records.value." describe("answer_commit numeric safety", () => { + test("description explains numeric precision and exact decimal strings", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect(tool.description).toContain( + "Numbers are accepted up to 15 significant digits; any decimal that must be reproduced exactly (more digits, trailing zeros, or currency strings) must be passed as a string.", + ) + }) + test.each(["9007199254740993", "-9007199254740993", "1e30"])("rejects unsafe field number %s", async (value) => { const payload = JSON.parse(`{"fields":[{"name":"Count","value":${value}}]}`) expect(() => AnswerCommit.renderAnswer(payload)).toThrow(/fields.*value.*safe.*string/i) From f2d018322608bf99aebc2d9d570b3631810942a9 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 17:46:45 -0700 Subject: [PATCH 14/39] fix(analyst): keep the full committed text through the per-result tool-output cap `SessionProcessor` caps every tool output to a model-dependent token budget before persisting it (a 4K-context model allows about 400 tokens), so a rendered answer longer than that was stored as a truncated preview and the echo logic reconstructed the committed text from that preview. The tool now also stores the complete rendered text in result metadata, which the cap does not touch, and the session loop reads the committed text from there (falling back to the output for older results). The model-facing output stays capped. Regression tests under `tool_output.dispatch_max_tokens: 100`: a 30-row commit whose persisted output is the capped preview still yields the full table as the final assistant text, and a short commit is unchanged. Co-Authored-By: Claude Fable 5.1 --- .../src/altimate/tools/answer-commit.ts | 6 ++- packages/opencode/src/session/prompt.ts | 16 ++++++-- .../opencode/test/agent/answer-commit.test.ts | 37 +++++++++++++++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 831c5649c5..1506195eac 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -232,10 +232,12 @@ export const AnswerCommitTool = Tool.define( if (ctx.agent !== "analyst") return yield* Effect.die(new Error("answer_commit is only available to the analyst agent")) yield* ctx.ask({ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }) + const output = renderAnswer(args) return { title: "Answer committed", - metadata: { readings: args.readings ?? [], truncated: false }, - output: renderAnswer(args), + // The processor caps model-facing output but preserves metadata for the final echo. + metadata: { readings: args.readings ?? [], truncated: false, committed_text: output }, + output, } }), }), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0d122588ab..1d4be69ee7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -897,7 +897,14 @@ export namespace SessionPrompt { lastAssistant.agent === "analyst" && commit?.type === "tool" && commit.state.status === "completed" - ? { userID: commitRequest.info.id, output: commit.state.output } + ? { + userID: commitRequest.info.id, + // Completed-tool metadata retains the full answer through the per-result output cap. + output: + typeof commit.state.metadata.committed_text === "string" + ? commit.state.metadata.committed_text + : commit.state.output, + } : undefined } // altimate_change end @@ -1734,7 +1741,10 @@ export namespace SessionPrompt { part.tool === "answer_commit" && part.state.status === "completed" && !part.state.time.compacted && - part.state.output === committedAnswer!.output, + // A capped preview still represents this commit; do not append its full text to the model prompt. + (typeof part.state.metadata.committed_text === "string" + ? part.state.metadata.committed_text + : part.state.output) === committedAnswer!.output, ), ) // altimate_change end @@ -1860,7 +1870,7 @@ export namespace SessionPrompt { // altimate_change end }) - // altimate_change start - finished echoes must preserve the committed output even at the model's output limit + // altimate_change start - finished echoes preserve the full committed text through tool and model output caps const echoFinished = echoCommittedAnswer && !!processor.message.finish && diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index dc797dc015..ae3ae34edc 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -6,6 +6,7 @@ import path from "node:path" import { ProjectV2 } from "@opencode-ai/core/project" import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" +import { renderAnswer } from "../../src/altimate/tools/answer-commit" import { ToolLookupTool } from "../../src/altimate/tools/tool-lookup" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" @@ -22,6 +23,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionTools } from "../../src/session/tools" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" +import { Token } from "../../src/util/token" import { initTool } from "../altimate/tool-fixture" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -352,6 +354,8 @@ for (const scenario of [ "echo-length", "echo-mismatch", "echo-exact-length", + "echo-capped", + "echo-under-cap", "json_schema", "json_schema-history", "new-user", @@ -363,11 +367,14 @@ for (const scenario of [ ] as const) { const structured = scenario.startsWith("json_schema") const priorCommit = scenario === "json_schema-history" + const tinyCap = scenario === "echo-capped" || scenario === "echo-under-cap" const name = { text: "text requests offer answer_commit and immediately echo a successful commit without a step limit", "echo-length": "a length-limited echo is replaced with the stored committed output verbatim", "echo-mismatch": "a mismatched echo is replaced with the stored committed output verbatim", "echo-exact-length": "an exact echo with a length finish ends normally with stop", + "echo-capped": "a dispatch-capped commit preserves the full rendered answer in the final assistant text", + "echo-under-cap": "a commit below the dispatch cap preserves the rendered answer in the final assistant text", json_schema: "json_schema requests omit answer_commit and never enter echo mode", "json_schema-history": "json_schema requests preserve masked commit history without re-advertising answer_commit", "new-user": "a new user message resets a successful commit before the next step", @@ -389,10 +396,18 @@ for (const scenario of [ const chat = yield* sessions.create({ title: "Answer commit finalization" }) const requests: { tools?: { function: { name: string } }[]; messages: unknown[]; tool_choice?: string }[] = [] const args = { - fields: [{ name: "", value: 42 }], + ...(scenario === "echo-capped" + ? { + rows: { + columns: ["Region", "Revenue"], + values: Array.from({ length: 30 }, (_, i) => [`Region ${i + 1}`, (i + 1) * 100]), + }, + } + : { fields: [{ name: "", value: 42 }] }), answer_only: true, basis: "Counted all records at record grain using records.id.", } + const renderedAnswer = renderAnswer(args) let queuedUserID: MessageID | undefined const insertUser = async () => { const userID = MessageID.ascending() @@ -580,14 +595,29 @@ for (const scenario of [ const last = requests[1].messages.at(-1) as { role?: string; content?: unknown } expect(last.role).toBe("user") expect(JSON.stringify(last.content)).toContain(echoInstruction) - expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: "42" })) + if (tinyCap) { + expect(Token.estimate(commit.state.output)).toBeLessThanOrEqual(100) + if (scenario === "echo-capped") { + expect(Token.estimate(renderedAnswer)).toBeGreaterThan(100) + expect(commit.state.output).not.toBe(renderedAnswer) + expect(commit.state.output).toContain("truncated before dispatch") + } else { + expect(commit.state.output).toBe(renderedAnswer) + } + expect(requests[1].messages).toContainEqual( + expect.objectContaining({ content: expect.stringContaining(commit.state.output) }), + ) + expect(last.content).toBe(echoInstruction) + } + expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: renderedAnswer })) expect(result.info).toHaveProperty("finish", "stop") expect( result.parts .filter((part) => part.type === "text") .map((part) => part.text) .join(""), - ).toBe(commit.state.output) + ).toBe(renderedAnswer) + if (scenario === "echo-capped") expect(commit.state.metadata.committed_text).toBe(renderedAnswer) if (scenario === "text") { // A correct echo needs only the stream's text-start and text-end writes, with no replacement. expect( @@ -624,6 +654,7 @@ for (const scenario of [ config: { snapshot: false, learn: { enabled: false }, + ...(tinyCap ? { tool_output: { dispatch_max_tokens: 100 } } : {}), agent: { analyst: { permission: { StructuredOutput: "allow" } } }, provider: { test: { From 49f37d0deb010746ed7a33f860b0c1f3b78dfea4 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 18:07:29 -0700 Subject: [PATCH 15/39] fix(analyst): deliver the committed echo to clients exactly once - The echo generation's text parts are buffered instead of streamed: the processor takes an opt-in buffer for echo mode and skips text start, delta, completion and the interrupted-text close, so clients never see an unvalidated echo. After the finish is validated the loop publishes one authoritative text part through the normal start, delta, completion sequence, which serves both the `run` output collector (completed parts) and ACP (deltas). A mismatched or length-limited echo therefore no longer prints the model's text and then the corrected text. - A `stop` finish with no text yields a committed text part with `time.start` and `time.end`, so `run` and `--output` no longer skip it. - Replacement and `stop` normalisation apply only to `stop` and `length` finishes; `other`, `error` and `content-filter` keep their text, timing and finish reason so `run` accounting still reports them. - Regression tests use real bus events, the real output collector with the `run` completed-text filter, and ACP-style delta collection: a mismatched echo delivers the committed output exactly once, a `stop` echo without text publishes a completed timed part, and an `other` finish is left untouched. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/processor.ts | 24 +++- packages/opencode/src/session/prompt.ts | 41 ++++--- .../opencode/test/agent/answer-commit.test.ts | 105 +++++++++++++----- 3 files changed, 124 insertions(+), 46 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 65d8e2191e..6d6158a616 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -326,7 +326,9 @@ export namespace SessionProcessor { return id === undefined ? undefined : toolcalls.get(id) }, // altimate_change end - async process(streamInput: LLM.StreamInput) { + // altimate_change start - opt-in text buffering for committed-answer echoes + async process({ deferText, ...streamInput }: LLM.StreamInput & { deferText?: MessageV2.TextPart[] }) { + // altimate_change end log.info("process") needsCompaction = false // altimate_change start — resolve breaker config + arm state once per step. @@ -1178,13 +1180,19 @@ export namespace SessionProcessor { }, metadata: value.providerMetadata, } - await Session.updatePart(currentText) + // altimate_change start - retain the live part through text deltas and completion + if (deferText) deferText.push(currentText) + else await Session.updatePart(currentText) + // altimate_change end break case "text-delta": if (currentText) { currentText.text += value.text if (value.providerMetadata) currentText.metadata = value.providerMetadata + // altimate_change start - unvalidated echo deltas must not reach clients + if (deferText) break + // altimate_change end await Session.updatePartDelta({ sessionID: currentText.sessionID, messageID: currentText.messageID, @@ -1213,7 +1221,9 @@ export namespace SessionProcessor { end: Date.now(), } if (value.providerMetadata) currentText.metadata = value.providerMetadata - await Session.updatePart(currentText) + // altimate_change start - publish the echo only after finish validation + if (!deferText) await Session.updatePart(currentText) + // altimate_change end } currentText = undefined break @@ -1261,7 +1271,8 @@ export namespace SessionProcessor { sessionID: input.sessionID, error, }) - await SessionStatus.set(input.sessionID, { type: "idle" }) + // The prompt loop publishes idle after flushing deferred text. + if (!deferText) await SessionStatus.set(input.sessionID, { type: "idle" }) // altimate_change end } else { needsCompaction = true @@ -1321,7 +1332,8 @@ export namespace SessionProcessor { }) // altimate_change end // altimate_change start — SessionStatus.set became async; await so idle state flushes before exit - await SessionStatus.set(input.sessionID, { type: "idle" }) + // The prompt loop publishes idle after flushing deferred text. + if (!deferText) await SessionStatus.set(input.sessionID, { type: "idle" }) // altimate_change end } // altimate_change start — end ordinary provider/session error handling @@ -1334,7 +1346,7 @@ export namespace SessionProcessor { const end = Date.now() if (currentText) { currentText.time = { start: currentText.time?.start ?? end, end } - await Session.updatePart(currentText) + if (!deferText) await Session.updatePart(currentText) } for (const part of Object.values(reasoningMap)) { part.time = { ...part.time, end } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1d4be69ee7..c32e08f725 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1732,6 +1732,7 @@ export namespace SessionPrompt { // altimate_change start - every successful commit makes the next generation an echo, including after compaction const echoCommittedAnswer = format.type === "text" && agent.name === "analyst" && committedAnswer !== undefined + const deferredText: MessageV2.TextPart[] = [] const committedOutputMissing = echoCommittedAnswer && !msgs.some((msg) => @@ -1863,6 +1864,7 @@ export namespace SessionPrompt { ], // altimate_change start - the final echo cannot spend another step on tools tools: echoCommittedAnswer ? {} : tools, + deferText: echoCommittedAnswer ? deferredText : undefined, // altimate_change end model, // altimate_change start - a user output schema still takes precedence over text finalization @@ -1870,20 +1872,17 @@ export namespace SessionPrompt { // altimate_change end }) - // altimate_change start - finished echoes preserve the full committed text through tool and model output caps + // altimate_change start - finalize buffered echoes before publishing any text to clients const echoFinished = echoCommittedAnswer && - !!processor.message.finish && - !["tool-calls", "unknown"].includes(processor.message.finish) && + (processor.message.finish === "stop" || processor.message.finish === "length") && !processor.message.error && !abort.aborted + let textParts = deferredText if (echoFinished && committedAnswer) { - const textParts = MessageV2.parts(processor.message.id).filter((part) => part.type === "text") - if ( - processor.message.finish === "length" || - textParts.map((part) => part.text).join("") !== committedAnswer.output - ) { - await Session.updatePart({ + // Collapse even a multipart echo into one authoritative completed text part. + textParts = [ + { ...(textParts[0] ?? { id: PartID.ascending(), sessionID, @@ -1891,16 +1890,30 @@ export namespace SessionPrompt { type: "text" as const, }), text: committedAnswer.output, - }) - for (const part of textParts.slice(1)) { - await Session.removePart({ sessionID, messageID: processor.message.id, partID: part.id }) - } - } + time: { + start: textParts[0]?.time?.start ?? processor.message.time.created, + end: Date.now(), + }, + }, + ] if (processor.message.finish !== "stop") { processor.message.finish = "stop" await Session.updateMessage(processor.message) } } + // Normal start -> delta -> completion delivery serves both delta-only ACP and + // run's completed-part output collector. Abnormal echoes retain their original text/timing. + for (const part of textParts) { + await Session.updatePart({ ...part, text: "", time: part.time && { start: part.time.start } }) + await Session.updatePartDelta({ + sessionID, + messageID: part.messageID, + partID: part.id, + field: "text", + delta: part.text, + }) + await Session.updatePart(part) + } // An input overflow has no finish, so it still follows the existing compaction retry below. // altimate_change end // If structured output was captured, save it and exit immediately diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index ae3ae34edc..56b34240b2 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -8,6 +8,8 @@ import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { renderAnswer } from "../../src/altimate/tools/answer-commit" import { ToolLookupTool } from "../../src/altimate/tools/tool-lookup" +import { GlobalBus, type GlobalEvent } from "../../src/bus/global" +import { OutputCollector } from "../../src/cli/cmd/run-output" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" @@ -353,6 +355,8 @@ for (const scenario of [ "text", "echo-length", "echo-mismatch", + "echo-no-text", + "echo-other", "echo-exact-length", "echo-capped", "echo-under-cap", @@ -371,7 +375,9 @@ for (const scenario of [ const name = { text: "text requests offer answer_commit and immediately echo a successful commit without a step limit", "echo-length": "a length-limited echo is replaced with the stored committed output verbatim", - "echo-mismatch": "a mismatched echo is replaced with the stored committed output verbatim", + "echo-mismatch": "a mismatched echo delivers the committed output exactly once to run output and ACP", + "echo-no-text": "a stop echo without text publishes the committed output as a completed timed text part", + "echo-other": "an echo with an other finish keeps its finish and original text", "echo-exact-length": "an exact echo with a length finish ends normally with stop", "echo-capped": "a dispatch-capped commit preserves the full rendered answer in the final assistant text", "echo-under-cap": "a commit below the dispatch cap preserves the rendered answer in the final assistant text", @@ -454,20 +460,22 @@ for (const scenario of [ : reply() .tool("answer_commit", scenario === "failed" ? {} : args) .item() - : structured && (!priorCommit || requests.length > 2) - ? reply().tool("StructuredOutput", { count: 42 }).item() - : reply() - .text( - scenario === "new-user" || scenario === "queued-user" || step === 3 - ? "3" - : scenario === "echo-length" - ? "4" - : scenario === "echo-mismatch" - ? "The answer is 42." - : "42", - ) - .stop() - .item() + : scenario === "echo-no-text" + ? reply().stop().item() + : structured && (!priorCommit || requests.length > 2) + ? reply().tool("StructuredOutput", { count: 42 }).item() + : reply() + .text( + scenario === "new-user" || scenario === "queued-user" || step === 3 + ? "3" + : scenario === "echo-length" + ? "4" + : scenario === "echo-mismatch" || scenario === "echo-other" + ? "The answer is 42." + : "42", + ) + .stop() + .item() if (response.type !== "sse") throw new Error("expected SSE response") const chunks = [...response.head, ...response.tail] if (step === 1 && scenario === "failed-after-success") { @@ -485,11 +493,15 @@ for (const scenario of [ } } } - if (step === 2 && (scenario === "echo-length" || scenario === "echo-exact-length")) { + if ( + step === 2 && + (scenario === "echo-length" || scenario === "echo-exact-length" || scenario === "echo-other") + ) { for (const chunk of chunks) { const event = chunk as { choices?: { finish_reason?: string }[] } for (const choice of event.choices ?? []) { - if (choice.finish_reason === "stop") choice.finish_reason = "length" + if (choice.finish_reason === "stop") + choice.finish_reason = scenario === "echo-other" ? "other" : "length" } } } @@ -508,7 +520,33 @@ for (const scenario of [ if (scenario === "queued-user" && requests.length === 1 && !queuedUserID) await insertUser() return messages }) - const partSpy = spyOn(LegacySession, "updatePart") + const output = new OutputCollector() + const textUpdates: MessageV2.TextPart[] = [] + const textPartIDs = new Set() + const textDeltas: { messageID: MessageID; text: string }[] = [] + const collect = ({ payload: event }: GlobalEvent) => { + if (event.type === MessageV2.Event.PartUpdated.type) { + const part = event.properties.part as MessageV2.Part + if (part.sessionID !== chat.id) return + if (part.type === "step-start") output.stepStart() + if (part.type !== "text") return + // Snapshot the published value: later mutations must not rewrite what a client observed. + textUpdates.push(structuredClone(part)) + textPartIDs.add(part.id) + // Match run.ts: only completed text parts are printed and collected for --output. + if (part.time?.end) output.text(part.text.trim(), { synthetic: part.synthetic === true }) + } + if ( + event.type === MessageV2.Event.PartDelta.type && + event.properties.sessionID === chat.id && + event.properties.field === "text" && + textPartIDs.has(event.properties.partID) + ) { + // ACP forwards deltas for known text parts and ignores later text updates. + textDeltas.push({ messageID: event.properties.messageID, text: event.properties.delta }) + } + } + GlobalBus.on("event", collect) try { if (priorCommit) { yield* prompt.prompt({ @@ -609,20 +647,35 @@ for (const scenario of [ ) expect(last.content).toBe(echoInstruction) } - expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: renderedAnswer })) - expect(result.info).toHaveProperty("finish", "stop") + const expectedText = scenario === "echo-other" ? "The answer is 42." : renderedAnswer + expect(result.info).toHaveProperty("finish", scenario === "echo-other" ? "other" : "stop") + expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: expectedText })) + if (scenario === "echo-no-text") { + const part = result.parts.find((part) => part.type === "text") + if (part?.type !== "text" || !part.time) throw new Error("missing completed echo text") + expect(typeof part.time.start).toBe("number") + expect(typeof part.time.end).toBe("number") + expect(part.time.end).toBeGreaterThanOrEqual(part.time.start) + } expect( result.parts .filter((part) => part.type === "text") .map((part) => part.text) .join(""), - ).toBe(renderedAnswer) + ).toBe(expectedText) + const finalUpdates = textUpdates.filter((part) => part.messageID === result.info.id) + expect(finalUpdates.filter((part) => part.time?.end).map((part) => part.text)).toEqual([expectedText]) + expect(output.content()).toBe(expectedText) + expect( + textDeltas + .filter((part) => part.messageID === result.info.id) + .map((part) => part.text) + .join(""), + ).toBe(expectedText) if (scenario === "echo-capped") expect(commit.state.metadata.committed_text).toBe(renderedAnswer) if (scenario === "text") { - // A correct echo needs only the stream's text-start and text-end writes, with no replacement. - expect( - partSpy.mock.calls.filter(([part]) => part.messageID === result.info.id && part.type === "text"), - ).toHaveLength(2) + // A correct echo publishes only its initial and completed text parts. + expect(finalUpdates).toHaveLength(2) } } else if (scenario === "user-during-echo") { expect(tools(1)).toEqual([]) @@ -644,7 +697,7 @@ for (const scenario of [ } } } finally { - partSpy.mockRestore() + GlobalBus.off("event", collect) historySpy.mockRestore() languageSpy.mockRestore() } From 9026032a9992ba85e15700a6c61f83ae86fa351e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 18:35:48 -0700 Subject: [PATCH 16/39] fix(analyst): finish from the committed text on echo transport errors; drop the prompt copy; allow the echo turn under `--max-turns` - An echo generation that ends in a provider or transport error (API, unknown transport, provider auth; not an abort, content filter, context overflow or HTTP policy rejection) now publishes the stored committed output through the same single start, delta, completion path as a successful echo and finishes with `stop`. The error is kept on the assistant message as `metadata.answer_commit_echo_error` instead of a fatal error, and the processor skips the fatal session-error event for that case so headless accounting reflects the recovered answer. Assistant messages gain an optional `metadata` field in both schemas. - The echo instruction no longer appends the full committed output when compaction hid the tool part; the loop replaces the echo text from the stored output anyway, and the copy could overflow the post-compaction input. - `run --max-turns N` allows step N+1 when the previous counted work message's last `answer_commit` attempt completed, so the committed echo is not aborted as an over-budget turn; a run without a completed commit still aborts at N+1. Help text and snapshot updated. - Regression tests: non-retryable echo transport failure publishes the committed output once and records the error in metadata; aborted, content-filtered and HTTP-policy-rejected echoes keep their fatal error; compaction cases assert no full-answer copy in the request; `--max-turns 1` run publishes the committed output on its echo turn. Co-Authored-By: Claude Fable 5.1 --- packages/core/src/v1/session.ts | 3 + packages/opencode/src/cli/cmd/run.ts | 17 ++++- packages/opencode/src/session/message-v2.ts | 11 +++ packages/opencode/src/session/processor.ts | 18 ++++- packages/opencode/src/session/prompt.ts | 38 +++++----- .../opencode/test/agent/answer-commit.test.ts | 74 ++++++++++++++++++- .../__snapshots__/help-snapshots.test.ts.snap | 6 +- .../test/cli/run/budget-finalization.test.ts | 52 +++++++++++++ packages/opencode/test/session/prompt.test.ts | 8 +- 9 files changed, 194 insertions(+), 33 deletions(-) diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index 34bb729683..21f26d173f 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -484,6 +484,9 @@ export const Assistant = Schema.Struct({ structured: Schema.optional(Schema.Any), variant: Schema.optional(Schema.String), finish: Schema.optional(Schema.String), + // altimate_change start — preserve committed-answer echo diagnostics across Effect message events + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + // altimate_change end }).annotate({ identifier: "AssistantMessage" }) export type Assistant = Omit>, "error"> & { error?: AssistantError diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 16108ff84d..6129bb197f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -404,7 +404,7 @@ export const RunCommand = cmd({ // altimate_change start — budget limits for CI/enterprise governance .option("max-turns", { type: "number", - describe: "maximum assistant work turns; one best-effort finalization generation may follow the limit", + describe: "maximum assistant work turns; allows one committed-answer echo, or one best-effort reply after a budget abort", }) // altimate_change start — wall-clock work budget, with a separate finalization allowance .option("time-budget", { @@ -936,6 +936,7 @@ You are speaking to a non-technical business executive. Follow these rules stric // altimate_change start — max-turns budget enforcement (count kept in accounting) const maxTurns = options?.turnLimit ?? args.maxTurns let budgetAborted = false + let lastWorkMessageID: string | undefined // altimate_change end // altimate_change start — parameterized stream @@ -1047,9 +1048,21 @@ You are speaking to a non-technical business executive. Follow these rules stric // the owning message's agent is resolved via the message.updated lookup // above, so compacting models are not differentially charged turns. const counted = accounting.onStepStart(part.messageID) + const previousMessageID = lastWorkMessageID + if (counted) lastWorkMessageID = part.messageID + let committedEcho = false + if (counted && maxTurns && accounting.turnCount === maxTurns + 1 && previousMessageID) { + // Inspect persisted parts: a later failed commit must revoke an earlier success. + const previous = await sdk.session.message( + { sessionID, messageID: previousMessageID }, + { signal: options?.signal ?? AbortSignal.timeout(5_000) }, + ).catch(() => undefined) + const commit = previous?.data?.parts.findLast((part) => part.type === "tool" && part.tool === "answer_commit") + committedEcho = commit?.type === "tool" && commit.state.status === "completed" + } // altimate_change start — the time-budget abort already owns the interrupted work phase if ( - counted && maxTurns && accounting.turnCount > maxTurns && !budgetAborted && + counted && maxTurns && accounting.turnCount > maxTurns && !committedEcho && !budgetAborted && (!timeExhausted() || options?.turnLimit !== undefined) ) { stopTimeBudget() diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index aea9f5f6f7..e6ea62d77f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -511,11 +511,22 @@ export namespace MessageV2 { structured: z.any().optional(), variant: z.string().optional(), finish: z.string().optional(), + // altimate_change start — retain recovered committed-answer echo failures on the assistant message + metadata: z.record(z.string(), z.unknown()).optional(), + // altimate_change end }).meta({ ref: "AssistantMessage", }) export type Assistant = z.infer + // altimate_change start — only provider/transport failures permit committed-answer echo recovery + export function isEchoTransportError(error: Assistant["error"]): boolean { + if (error?.name !== "APIError" && error?.name !== "UnknownError" && error?.name !== "ProviderAuthError") return false + // HTTP policy rejections can arrive as APIError rather than a content-filter finish. + const detail = error.data.message + (error.name === "APIError" ? `\n${error.data.responseBody ?? ""}` : "") + return !/content[-_\s]?(?:filter|policy)|safety|guardrail[_\s-]intervened|ResponsibleAIPolicyViolation/i.test(detail) + } + // altimate_change end export const Info = z.discriminatedUnion("role", [User, Assistant]).meta({ ref: "Message", }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 6d6158a616..75956773a7 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1314,10 +1314,20 @@ export namespace SessionProcessor { // Publish the error before idle. The run harness drains this // generation through idle before opening a challenge stream; an // unawaited publish can otherwise arrive on that fresh stream. - await Bus.publish(Session.Event.Error, { - sessionID: input.assistantMessage.sessionID, - error: input.assistantMessage.error, - }) + // The prompt loop recovers a buffered committed-answer echo and records + // its transport error as message metadata before publishing the stored text. + // Publishing a fatal event here would permanently fail headless run accounting. + if ( + !deferText || + input.abort.aborted || + input.assistantMessage.finish === "content-filter" || + !MessageV2.isEchoTransportError(error) + ) { + await Bus.publish(Session.Event.Error, { + sessionID: input.assistantMessage.sessionID, + error: input.assistantMessage.error, + }) + } // altimate_change end // altimate_change start — telemetry for unhandled streaming errors (non-retry, non-overflow) // Covers: MessageAbortedError (Stop/dispose), UnknownError (SSE chunk timeout), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c32e08f725..3f5d7c7e03 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1733,21 +1733,6 @@ export namespace SessionPrompt { const echoCommittedAnswer = format.type === "text" && agent.name === "analyst" && committedAnswer !== undefined const deferredText: MessageV2.TextPart[] = [] - const committedOutputMissing = - echoCommittedAnswer && - !msgs.some((msg) => - msg.parts.some( - (part) => - part.type === "tool" && - part.tool === "answer_commit" && - part.state.status === "completed" && - !part.state.time.compacted && - // A capped preview still represents this commit; do not append its full text to the model prompt. - (typeof part.state.metadata.committed_text === "string" - ? part.state.metadata.committed_text - : part.state.output) === committedAnswer!.output, - ), - ) // altimate_change end // altimate_change start - trace system prompt once per loop() call. @@ -1849,7 +1834,7 @@ export namespace SessionPrompt { // assistant message ("conversation must end with a user message"), and every // provider accepts an instruction as the final user turn. role: "user" as const, - content: `Tools are disabled. Reply with exactly the text returned by answer_commit and nothing else.${committedOutputMissing ? `\n\n${committedAnswer!.output}` : ""}`, + content: "Tools are disabled. Reply with exactly the text returned by answer_commit and nothing else.", }, ] : isLastStep @@ -1873,10 +1858,18 @@ export namespace SessionPrompt { }) // altimate_change start - finalize buffered echoes before publishing any text to clients + const echoTransportError = + echoCommittedAnswer && + !abort.aborted && + processor.message.finish !== "content-filter" && + MessageV2.isEchoTransportError(processor.message.error) + ? processor.message.error + : undefined const echoFinished = echoCommittedAnswer && - (processor.message.finish === "stop" || processor.message.finish === "length") && - !processor.message.error && + (echoTransportError || + ((processor.message.finish === "stop" || processor.message.finish === "length") && + !processor.message.error)) && !abort.aborted let textParts = deferredText if (echoFinished && committedAnswer) { @@ -1896,7 +1889,14 @@ export namespace SessionPrompt { }, }, ] - if (processor.message.finish !== "stop") { + if (echoTransportError) { + processor.message.metadata = { + ...processor.message.metadata, + answer_commit_echo_error: echoTransportError, + } + delete processor.message.error + } + if (processor.message.finish !== "stop" || echoTransportError) { processor.message.finish = "stop" await Session.updateMessage(processor.message) } diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 56b34240b2..824185bcfc 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -351,11 +351,16 @@ describe("answer_commit agent scope", () => { ) }) +// altimate_change start — committed-answer echo transport and delivery regressions for (const scenario of [ "text", "echo-length", "echo-mismatch", "echo-no-text", + "echo-transport-error", + "echo-aborted", + "echo-content-filter", + "echo-http-content-filter", "echo-other", "echo-exact-length", "echo-capped", @@ -377,6 +382,10 @@ for (const scenario of [ "echo-length": "a length-limited echo is replaced with the stored committed output verbatim", "echo-mismatch": "a mismatched echo delivers the committed output exactly once to run output and ACP", "echo-no-text": "a stop echo without text publishes the committed output as a completed timed text part", + "echo-transport-error": "a non-retryable echo transport failure publishes the committed output and records metadata", + "echo-aborted": "an aborted echo retains its fatal error without publishing the committed output", + "echo-content-filter": "a content-filtered echo retains its fatal error and original text", + "echo-http-content-filter": "an HTTP content-filtered echo retains its fatal error without publishing the committed output", "echo-other": "an echo with an other finish keeps its finish and original text", "echo-exact-length": "an exact echo with a length finish ends normally with stop", "echo-capped": "a dispatch-capped commit preserves the full rendered answer in the final assistant text", @@ -444,6 +453,19 @@ for (const scenario of [ if (requests.length > (priorCommit ? 4 : scenario === "user-during-echo" ? 3 : 2)) throw new Error("unexpected extra generation") const step = priorCommit && requests.length > 2 ? requests.length - 2 : requests.length + if (step === 2 && (scenario === "echo-transport-error" || scenario === "echo-http-content-filter")) { + const error = + scenario === "echo-http-content-filter" + ? { code: "content_filter", message: "Echo request blocked by content policy" } + : { message: "Echo transport rejected the request" } + return new Response(JSON.stringify({ error }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }) + } + if (step === 2 && scenario === "echo-aborted") { + throw new DOMException("Echo request aborted", "AbortError") + } if ( (scenario === "new-user" && requests.length === 1) || (scenario === "user-during-echo" && requests.length === 2) @@ -495,13 +517,17 @@ for (const scenario of [ } if ( step === 2 && - (scenario === "echo-length" || scenario === "echo-exact-length" || scenario === "echo-other") + (scenario === "echo-length" || + scenario === "echo-exact-length" || + scenario === "echo-other" || + scenario === "echo-content-filter") ) { for (const chunk of chunks) { const event = chunk as { choices?: { finish_reason?: string }[] } for (const choice of event.choices ?? []) { if (choice.finish_reason === "stop") - choice.finish_reason = scenario === "echo-other" ? "other" : "length" + choice.finish_reason = + scenario === "echo-content-filter" ? "content_filter" : scenario === "echo-other" ? "other" : "length" } } } @@ -524,7 +550,11 @@ for (const scenario of [ const textUpdates: MessageV2.TextPart[] = [] const textPartIDs = new Set() const textDeltas: { messageID: MessageID; text: string }[] = [] + const sessionErrors: unknown[] = [] const collect = ({ payload: event }: GlobalEvent) => { + if (event.type === "session.error" && event.properties.sessionID === chat.id) { + sessionErrors.push(event.properties.error) + } if (event.type === MessageV2.Event.PartUpdated.type) { const part = event.properties.part as MessageV2.Part if (part.sessionID !== chat.id) return @@ -594,7 +624,44 @@ for (const scenario of [ } : {}), }) + if ( + scenario === "echo-aborted" || scenario === "echo-content-filter" || scenario === "echo-http-content-filter" + ) { + expect(result.info).toHaveProperty( + "error.name", + scenario === "echo-aborted" + ? "MessageAbortedError" + : scenario === "echo-http-content-filter" ? "APIError" : "ContentFilterError", + ) + expect(result.info).not.toHaveProperty("metadata.answer_commit_echo_error") + expect(result.info).not.toHaveProperty("finish", "stop") + expect(requests).toHaveLength(2) + expect(sessionErrors).toHaveLength(1) + if (scenario !== "echo-content-filter") { + expect(result.parts.filter((part) => part.type === "text")).toEqual([]) + expect(output.content()).toBe("(no text output — tool-only response)") + expect(textDeltas.filter((part) => part.messageID === result.info.id)).toEqual([]) + } else { + expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: "42" })) + } + return + } expect(result.info).not.toHaveProperty("error") + if (scenario === "echo-transport-error") { + expect(sessionErrors).toEqual([]) + expect(result.info).toHaveProperty( + "metadata.answer_commit_echo_error.data.message", + "Echo transport rejected the request", + ) + const persisted = (yield* sessions.messages({ sessionID: chat.id })).find( + (message) => message.info.id === result.info.id, + ) + expect(persisted?.info).toHaveProperty( + "metadata.answer_commit_echo_error.data.message", + "Echo transport rejected the request", + ) + expect(persisted?.info).not.toHaveProperty("error") + } expect(requests).toHaveLength(priorCommit ? 4 : scenario === "user-during-echo" ? 3 : 2) const tools = (index: number) => (requests[index].tools ?? []).map((tool) => tool.function.name) if (structured) { @@ -673,7 +740,7 @@ for (const scenario of [ .join(""), ).toBe(expectedText) if (scenario === "echo-capped") expect(commit.state.metadata.committed_text).toBe(renderedAnswer) - if (scenario === "text") { + if (scenario === "text" || scenario === "echo-transport-error") { // A correct echo publishes only its initial and completed text parts. expect(finalUpdates).toHaveLength(2) } @@ -729,6 +796,7 @@ for (const scenario of [ 30_000, ) } +// altimate_change end test("analyst prompt gives StructuredOutput precedence only when an output JSON schema is supplied", async () => { const prompt = await Bun.file(new URL("../../src/altimate/prompts/analyst.txt", import.meta.url)).text() diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index ac7390182b..7f8dbbce66 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -81,6 +81,7 @@ Options: [string]" `; +// altimate_change start — document the committed-answer echo allowance in run help exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = ` "altimate-code run [message..] @@ -134,14 +135,15 @@ Options: statement (1-indexed) [number] --trace enable session tracing (default: true, disable with --no-trace) [boolean] [default: true] - --max-turns maximum assistant work turns; one best-effort finalization - generation may follow the limit [number] + --max-turns maximum assistant work turns; allows one committed-answer + echo, or one best-effort reply after a budget abort [number] --time-budget maximum work time in seconds (env: ALTIMATE_RUN_TIME_BUDGET_S); bounded finalization may follow [number] --dangerously-skip-permissions auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false]" `; +// altimate_change end exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = ` "altimate-code debug diff --git a/packages/opencode/test/cli/run/budget-finalization.test.ts b/packages/opencode/test/cli/run/budget-finalization.test.ts index 2bcf7deb82..23ca1151ca 100644 --- a/packages/opencode/test/cli/run/budget-finalization.test.ts +++ b/packages/opencode/test/cli/run/budget-finalization.test.ts @@ -199,6 +199,53 @@ function isFinalization(body: Record) { } describe("opencode run: budget finalization subprocess", () => { + // altimate_change start — a completed answer gets one tool-free echo beyond the work-turn limit + cliIt.concurrent( + "answer_commit on --max-turns 1 publishes the committed output on its extra echo turn", + ({ llm, opencode }) => + Effect.gen(function* () { + const committed = "Committed revenue: 42" + yield* llm.tool("answer_commit", { + fields: [{ name: "", value: committed }], + answer_only: true, + basis: "Summed orders.amount over all orders at order grain.", + }) + yield* llm.text("A different provider echo must be replaced.") + // The old budget check aborts the echo and requests this fallback instead. + yield* llm.textMatch(({ body }) => isFinalization(body), "The echo was aborted by the turn limit.") + + const result = yield* opencode.run("Commit the revenue answer.", { + agent: "analyst", + format: "json", + extraArgs: ["--max-turns", "1"], + env: { ALTIMATE_ANSWER_COMMIT: "1" }, + timeoutMs: 60_000, + bunRun: true, + }) + opencode.expectExit(result, 0) + const events = opencode.parseJsonEvents(result.stdout) + expect(events.filter((event) => event.type === "budget_exhausted")).toHaveLength(0) + expect(events.filter((event) => event.type === "silent_turn_reply")).toHaveLength(0) + expect(events.filter((event) => event.type === "step_start")).toHaveLength(2) + expect(events.filter((event) => event.type === "tool_use").map((event) => event.part)).toContainEqual( + expect.objectContaining({ tool: "answer_commit", state: expect.objectContaining({ status: "completed" }) }), + ) + expect(events.filter((event) => event.type === "text").map((event) => (event.part as { text: string }).text)).toEqual([ + committed, + ]) + expect(events.filter((event) => event.type === "termination")).toMatchObject([ + { why_model_stopped: "stop", why_harness_stopped: "none" }, + ]) + const requests = (yield* llm.inputs).filter( + (body) => !JSON.stringify(body).includes("Generate a title for this conversation"), + ) + expect(requests).toHaveLength(2) + expect(requests[1]!.tools ?? []).toEqual([]) + expect(requests.filter(isFinalization)).toHaveLength(0) + }), + 90_000, + ) + // altimate_change end // altimate_change start — exhausted budgets must disable execution even if the model calls a tool for (const budget of ["turn", "time"] as const) { cliIt.concurrent( @@ -311,6 +358,11 @@ describe("opencode run: budget finalization subprocess", () => { const events = opencode.parseJsonEvents(result.stdout) expect(events.filter((event) => event.type === "budget_exhausted")).toHaveLength(1) expect(events.filter((event) => event.type === "silent_turn_reply")).toHaveLength(1) + // altimate_change start — a non-commit still stops before the second work step is emitted + const budgetIndex = events.findIndex((event) => event.type === "budget_exhausted") + expect(events[budgetIndex]).toMatchObject({ limit: 1 }) + expect(events.slice(0, budgetIndex).filter((event) => event.type === "step_start")).toHaveLength(1) + // altimate_change end expect(events.filter((event) => event.type === "termination")).toMatchObject([ { why_model_stopped: "stop", why_harness_stopped: "budget-exhausted", done_reason: "none" }, ]) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 00280d91c8..76c1644644 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1077,7 +1077,7 @@ for (const mode of [ : mode === "structured" ? reply().tool("StructuredOutput", { count: 42 }).item() : reply() - .text(limited ? "Maximum steps reached." : answer) + .text(limited ? "Maximum steps reached." : compacts ? "The rows were compacted away." : answer) .stop() .item() if (response.type !== "sse") throw new Error("expected SSE response") @@ -1123,12 +1123,14 @@ for (const mode of [ const finalRequest = requests.at(-1)! expect(JSON.stringify(finalRequest.messages)).not.toContain("CRITICAL - MAXIMUM STEPS REACHED") expect(finalRequest.tools ?? []).toHaveLength(0) - if (!compacts) expect(requestText(requestMessages(finalRequest).at(-1)!.content)).not.toContain(answer) + expect(requestText(requestMessages(finalRequest).at(-1)!.content)).toBe( + "Tools are disabled. Reply with exactly the text returned by answer_commit and nothing else.", + ) if (compacts) { expect(messages.some((message) => message.info.role === "assistant" && message.info.summary)).toBe(true) expect( requestMessages(finalRequest).some((message) => requestText(message.content).includes(answer)), - ).toBe(true) + ).toBe(false) expect(commit?.state.status === "completed" && commit.state.metadata.truncated).toBe(false) } expect( From 98dc062dcd762b481a1401a14c0bb39989a9fb20 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 18:51:00 -0700 Subject: [PATCH 17/39] fix(analyst): deliver the committed text on an error-free `other` echo finish An error-free echo that finished with the provider reason `other` published the model's buffered text unchanged, so a provider mapping an unrecognised stop reason to `other` could replace the committed answer with a mismatched echo. Error-free, non-aborted `stop`, `length` and `other` echoes now all deliver the stored committed output; only `length` (and the transport-error recovery) normalise the finish to `stop`, so `other` stays reported to headless accounting. Aborts, content filters and unrecovered errors are unchanged. Test `an echo with an other finish keeps its finish and delivers the committed output exactly once` replaces the earlier original-text assertion. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/prompt.ts | 9 ++++++--- packages/opencode/test/agent/answer-commit.test.ts | 11 +++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3f5d7c7e03..aec0ad2418 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1868,7 +1868,9 @@ export namespace SessionPrompt { const echoFinished = echoCommittedAnswer && (echoTransportError || - ((processor.message.finish === "stop" || processor.message.finish === "length") && + ((processor.message.finish === "stop" || + processor.message.finish === "length" || + processor.message.finish === "other") && !processor.message.error)) && !abort.aborted let textParts = deferredText @@ -1896,13 +1898,14 @@ export namespace SessionPrompt { } delete processor.message.error } - if (processor.message.finish !== "stop" || echoTransportError) { + if (processor.message.finish === "length" || echoTransportError) { processor.message.finish = "stop" await Session.updateMessage(processor.message) } } // Normal start -> delta -> completion delivery serves both delta-only ACP and - // run's completed-part output collector. Abnormal echoes retain their original text/timing. + // run's completed-part output collector. Error-free "other" echoes use committed text + // while retaining their finish; aborted, content-filtered, and unrecovered errors retain original text/timing. for (const part of textParts) { await Session.updatePart({ ...part, text: "", time: part.time && { start: part.time.start } }) await Session.updatePartDelta({ diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 824185bcfc..90da84f7ca 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -386,7 +386,7 @@ for (const scenario of [ "echo-aborted": "an aborted echo retains its fatal error without publishing the committed output", "echo-content-filter": "a content-filtered echo retains its fatal error and original text", "echo-http-content-filter": "an HTTP content-filtered echo retains its fatal error without publishing the committed output", - "echo-other": "an echo with an other finish keeps its finish and original text", + "echo-other": "an echo with an other finish keeps its finish and delivers the committed output exactly once", "echo-exact-length": "an exact echo with a length finish ends normally with stop", "echo-capped": "a dispatch-capped commit preserves the full rendered answer in the final assistant text", "echo-under-cap": "a commit below the dispatch cap preserves the rendered answer in the final assistant text", @@ -714,7 +714,7 @@ for (const scenario of [ ) expect(last.content).toBe(echoInstruction) } - const expectedText = scenario === "echo-other" ? "The answer is 42." : renderedAnswer + const expectedText = renderedAnswer expect(result.info).toHaveProperty("finish", scenario === "echo-other" ? "other" : "stop") expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: expectedText })) if (scenario === "echo-no-text") { @@ -736,11 +736,10 @@ for (const scenario of [ expect( textDeltas .filter((part) => part.messageID === result.info.id) - .map((part) => part.text) - .join(""), - ).toBe(expectedText) + .map((part) => part.text), + ).toEqual([expectedText]) if (scenario === "echo-capped") expect(commit.state.metadata.committed_text).toBe(renderedAnswer) - if (scenario === "text" || scenario === "echo-transport-error") { + if (scenario === "text" || scenario === "echo-transport-error" || scenario === "echo-other") { // A correct echo publishes only its initial and completed text parts. expect(finalUpdates).toHaveLength(2) } From 0b52f4f025e43842d575d745e88b742f17306032 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 19:09:11 -0700 Subject: [PATCH 18/39] fix(analyst): deliver the committed text when the echo finish is unmapped A finish reason outside the SDK's set is mapped to `unknown`. An error-free echo ending that way was excluded from committed-text delivery, and because the loop treats `unknown` as unfinished it then ran another tool-enabled generation instead of returning the committed answer. Such an echo now delivers the stored output like `stop`/`length`/`other` and ends as a normal `stop`, since the real reason is unknown and the text was delivered. Aborts, content filters and unrecovered errors are unchanged. The OpenAI-compatible adapter used by the agent tests maps every unrecognised provider reason to `other` (covered by the existing `other` scenario), so the `unknown` branch has no provider-level fixture here; it mirrors that branch and is covered by typecheck. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/prompt.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index aec0ad2418..166bf3c749 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1870,7 +1870,8 @@ export namespace SessionPrompt { (echoTransportError || ((processor.message.finish === "stop" || processor.message.finish === "length" || - processor.message.finish === "other") && + processor.message.finish === "other" || + processor.message.finish === "unknown") && !processor.message.error)) && !abort.aborted let textParts = deferredText @@ -1898,14 +1899,17 @@ export namespace SessionPrompt { } delete processor.message.error } - if (processor.message.finish === "length" || echoTransportError) { + // "unknown" is an unmapped provider reason; the loop treats it as unfinished, so a + // delivered committed answer must end as a normal stop. + if (processor.message.finish === "length" || processor.message.finish === "unknown" || echoTransportError) { processor.message.finish = "stop" await Session.updateMessage(processor.message) } } // Normal start -> delta -> completion delivery serves both delta-only ACP and // run's completed-part output collector. Error-free "other" echoes use committed text - // while retaining their finish; aborted, content-filtered, and unrecovered errors retain original text/timing. + // while retaining their finish; "unknown" ones use it and finish as stop; aborted, + // content-filtered, and unrecovered errors retain original text/timing. for (const part of textParts) { await Session.updatePart({ ...part, text: "", time: part.time && { start: part.time.start } }) await Session.updatePartDelta({ From fd0bc412f38207ccbab5e89b42ce23f5e42a038e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 19:25:02 -0700 Subject: [PATCH 19/39] fix(analyst): bound `basis` by length, state the precision range, add per-column row precision Live sessions produced two avoidable `answer_commit` validation errors: the "at most two sentences" check split a basis on "U.S.", and a `precision` of 14 was rejected without the bound being visible to the model. `basis` is now bounded by `MAX_BASIS_CHARACTERS` (400) instead of a punctuation-based sentence count, and the description and validation message state the precision range (0 to 10). `rows` gains an optional `precision` map keyed by column name so numeric cells, including integers, render with fixed decimals; strings, nulls and unlisted columns are unchanged. Live sessions showed a numeric `5` rendered where the equivalent `"5.00"` was expected. Tests: two-sentence basis with "U.S." and "e.g." accepted; 400 characters accepted, 401 rejected naming the bound; precision range stated in the description and in the error for 14; row integers with precision 2 render `5.00`, without precision `5`, numeric strings keep trailing zeros, precision applies independently per column; the Anthropic draft-2020-12 schema test still passes. Co-Authored-By: Claude Fable 5.1 --- .../src/altimate/tools/answer-commit.ts | 41 +++-- .../test/altimate/tools/answer-commit.test.ts | 171 +++++++++++++----- 2 files changed, 146 insertions(+), 66 deletions(-) diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 1506195eac..9a66319c6c 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -5,6 +5,7 @@ import { Tool } from "../../tool/tool" /** Maximum rendered answer length, including Markdown and basis, before the model echoes it. */ export const MAX_RENDERED_ANSWER_CHARACTERS = 60_000 +export const MAX_BASIS_CHARACTERS = 400 const renderedLengthGuidance = `Rendered answer text must not exceed ${MAX_RENDERED_ANSWER_CHARACTERS.toLocaleString("en-US")} characters; aggregate, narrow the result, or write the full table to a file and commit the summary.` @@ -32,6 +33,9 @@ const number = z "Numeric values must have at most 15 significant digits and integers must be safe; pass the exact value as a string", ) +const precisionRange = "precision must be an integer from 0 to 10 (inclusive)" +const precision = z.number({ error: precisionRange }).int(precisionRange).min(0, precisionRange).max(10, precisionRange) + const field = z .object({ name: z.string().describe("Field label; use an empty name for an unnamed value"), @@ -39,8 +43,8 @@ const field = z .union([z.string(), number], { error: "Each field value must be one non-empty string or number" }) .refine((value) => typeof value !== "string" || value.trim().length > 0, "Field value must be non-empty"), units: optional(z.string()), - precision: optional(z.number().int().min(0).max(10)).describe( - "Decimal places for numeric values; integers stay integers", + precision: optional(precision).describe( + "Decimal places for numeric values: integer from 0 to 10 (inclusive); integers stay integers", ), }) .strict() @@ -54,6 +58,9 @@ const rows = z .describe( "Concrete result rows; when the request names N, the row count must equal N, even with a tie at the top-N boundary", ), + precision: optional(z.record(z.string(), precision)).describe( + "Display decimal places by column name: integer from 0 to 10 (inclusive). Numeric cells use fixed decimals, including integers; strings and nulls are unchanged. Omitted columns keep their default formatting.", + ), note: optional(z.string()).describe( "Brief result note, including any tie at the top-N boundary; omitted when answer_only is true", ), @@ -71,8 +78,7 @@ const rows = z }) }) -const basisRequired = - "basis is always required: supply one or two sentences naming population, grain, source object/column, and caveats" +const basisRequired = `basis is always required: supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, source object/column, and caveats` const parameters = z .object({ @@ -100,17 +106,10 @@ const parameters = z ), basis: z .string({ error: basisRequired }) + .max(MAX_BASIS_CHARACTERS, `basis must contain at most ${MAX_BASIS_CHARACTERS} characters`) .refine((value) => value.trim().length > 0, basisRequired) - .refine( - (value) => - value - .replace(/\b(?:e\.g|i\.e|etc|Dr|vs|approx)\./gi, (word) => word.replaceAll(".", "")) - .split(/[.?!](?:\s+(?=\S)|$)/u) - .filter((part) => part.trim()).length <= 2, - "basis must contain at most 2 sentences", - ) .describe( - "basis is always required: one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats; never rendered with answer_only", + `basis is always required: one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats; never rendered with answer_only`, ), answer_only: optional(z.boolean()), readings: optional( @@ -195,11 +194,19 @@ export function renderAnswer(payload: unknown): string { return limitRenderedAnswer(args.answer_only ? values : `${basis}**Answer:** ${values}`) } if (args.rows) { + const { columns, values, precision } = args.rows const row = (cells: (string | number | null)[]) => `| ${cells.map(tableCell).join(" | ")} |` const table = [ - row(args.rows.columns), - `|${args.rows.columns.map(() => "---").join("|")}|`, - ...args.rows.values.map(row), + row(columns), + `|${columns.map(() => "---").join("|")}|`, + ...values.map((cells) => + row( + cells.map((value, index) => { + const places = precision && Object.hasOwn(precision, columns[index]!) ? precision[columns[index]!] : undefined + return typeof value === "number" && places !== undefined ? value.toFixed(places) : value + }), + ), + ), ].join("\n") return limitRenderedAnswer( args.answer_only ? table : `${basis}**Answer:**\n${table}${args.rows.note ? `\n${args.rows.note}` : ""}`, @@ -221,7 +228,7 @@ export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ description: - `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Numbers are accepted up to 15 significant digits; any decimal that must be reproduced exactly (more digits, trailing zeros, or currency strings) must be passed as a string. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, + `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Numbers are accepted up to 15 significant digits; any decimal that must be reproduced exactly (more digits, trailing zeros, or currency strings) must be passed as a string. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, parameters: Parameters, formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)), // Preserve the existing wire schema; Zod and the SDK use different recursive JSON Schema types. diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index 9b721d12fa..790121a4de 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -94,7 +94,7 @@ describe("answer_commit required basis", () => { "Always supply `basis`; it is never shown when the request asks for only the answer", ) expect(tool.description).toContain( - "one or two sentences naming population, grain, the source object/column that supplies the requested entity or value, and caveats", + "one or two sentences, at most 400 characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats", ) expect(tool.description).toContain( "Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in `basis` which source object and column supply it.", @@ -102,7 +102,7 @@ describe("answer_commit required basis", () => { expect(structuredClone(tool.jsonSchema?.properties?.basis)).toMatchObject({ type: "string", description: expect.stringMatching( - /always required.*population.*grain.*source object\/column.*caveats.*never rendered with answer_only/i, + /always required.*one or two sentences, at most 400 characters.*population.*grain.*source object\/column.*caveats.*never rendered with answer_only/i, ), }) expect(tool.jsonSchema?.required).toContain("basis") @@ -243,6 +243,93 @@ describe("answer_commit numeric safety", () => { ) }) +describe("answer_commit display precision", () => { + test("description and field and row schemas state the allowed precision range", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + const range = /integer from 0 to 10 \(inclusive\)/i + expect(tool.description).toMatch(range) + expect(structuredClone(tool.jsonSchema?.properties?.fields)).toMatchObject({ + anyOf: expect.arrayContaining([ + expect.objectContaining({ + items: expect.objectContaining({ + properties: expect.objectContaining({ + precision: expect.objectContaining({ description: expect.stringMatching(range) }), + }), + }), + }), + ]), + }) + expect(structuredClone(tool.jsonSchema?.properties?.rows)).toMatchObject({ + anyOf: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + precision: expect.objectContaining({ description: expect.stringMatching(range) }), + }), + }), + ]), + }) + }) + + test.each([-1, 11, 14, 1.5, "2", true])("invalid precision %j names the allowed range", async (precision) => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + for (const shape of [ + { fields: [{ name: "", value: 1.5, precision }] }, + { rows: { columns: ["Value"], values: [[5]], precision: { Value: precision } } }, + ]) { + const payload = { basis: recordBasis, ...shape } + const error = /precision.*integer from 0 to 10 \(inclusive\)/i + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(error) + await rejects(tool.execute(payload, context), error) + } + }) + + test.each([ + ["numeric integers use fixed decimals", 5, 2, "5.00"], + ["without precision uses the shortest numeric form", 5, undefined, "5"], + ["numeric strings retain trailing zeros", "5.00", 0, "5.00"], + ["numeric strings retain leading zeros", "001.20", 10, "001.20"], + ["text is unchanged", "unchanged", 2, "unchanged"], + ["null is unchanged", null, 2, "null"], + ["zero precision rounds numeric cells", 1.6, 0, "2"], + ["maximum precision includes ten decimals", 5, 10, "5.0000000000"], + ] as [string, string | number | null, number | undefined, string][])( + "rows %s", + async (_name, value, precision, rendered) => { + const payload = { + basis: recordBasis, + rows: { + columns: ["Value"], + values: [[value]], + ...(precision === undefined ? {} : { precision: { Value: precision } }), + }, + } + const expected = `${recordBasis}\n\n**Answer:**\n| Value |\n|---|\n| ${rendered} |` + expect(AnswerCommit.renderAnswer(payload)).toBe(expected) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect((await tool.execute(payload, context)).output).toBe(expected) + }, + ) + + test("row precision applies independently by column name", async () => { + const payload = { + basis: recordBasis, + answer_only: true, + rows: { + columns: ["Quantity", "Rate", "Unspecified"], + values: [ + [5, 1.234, 5], + ["5.00", null, "001.20"], + ], + precision: { Rate: 1, Quantity: 2 }, + }, + } + const expected = "| Quantity | Rate | Unspecified |\n|---|---|---|\n| 5.00 | 1.2 | 5 |\n| 5.00 | null | 001.20 |" + expect(AnswerCommit.renderAnswer(payload)).toBe(expected) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect((await tool.execute(payload, context)).output).toBe(expected) + }) +}) + describe("answer_commit rendered length limit", () => { const cap = 60_000 const error = /60,000 characters.*aggregate.*narrow.*write the full table to a file.*commit the summary/i @@ -579,52 +666,44 @@ describe("renderAnswer strict-schema null fillers", () => { }) }) -describe("renderAnswer basis sentence boundaries", () => { - test.each(["e.g.", "i.e.", "etc.", "Dr.", "vs.", "approx."])("does not count %s as a sentence end", (word) => { - const basis = `Used ${word} Completed orders. Count is 2.` - expect(AnswerCommit.renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) - }) - - test("does not count decimals as sentence ends", () => { - const basis = "The mean is 3.5 and the median is 2.5. Count is 2." - expect(AnswerCommit.renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) - }) - - test("punctuation followed by lowercase text counts as a sentence boundary", () => { - const basis = "Count? maybe! still checking. Count is 2." - expect(() => AnswerCommit.renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) - }) - - test("caps lowercase sentences separated by newlines", () => { - expect(() => AnswerCommit.renderAnswer({ basis: "one.\ntwo.\nthree.", fields: [{ name: "", value: 2 }] })).toThrow( - /basis.*2 sentences/i, - ) - }) - - test.each(["2 orders", '"two orders"'])("counts sentence boundaries before %s", (next) => { - const basis = `First. ${next}. Third.` - expect(() => AnswerCommit.renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) +describe("renderAnswer basis length bound", () => { + test("accepts a two-sentence basis containing U.S. and e.g.", async () => { + const basis = "Counted U.S. orders at order grain from orders.id. Excluded cancelled orders, e.g. pending refunds." + const payload = { basis, fields: [{ name: "", value: 2 }] } + const expected = `${basis}\n\n**Answer:** 2` + expect(AnswerCommit.renderAnswer(payload)).toBe(expected) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect((await tool.execute(payload, context)).output).toBe(expected) }) - test.each([".", "?", "!"])("caps sentences separated by %s and uppercase text", (punctuation) => { - const basis = `First${punctuation} Second${punctuation} Third${punctuation}` - expect(() => AnswerCommit.renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toThrow(/basis.*2 sentences/i) - }) + test.each(["First. Second? Third!", "one.\ntwo.\nthree.", "One.\n\nTwo.\n\nThree."])( + "accepts punctuation within the length bound: %s", + (basis) => { + expect(AnswerCommit.renderAnswer({ basis, fields: [{ name: "", value: 2 }] })).toBe(`${basis}\n\n**Answer:** 2`) + }, + ) - test("abbreviations and decimals do not hide a third sentence", () => { - expect(() => - AnswerCommit.renderAnswer({ - basis: "Used e.g. Orders. Mean is 3.5. Count is 2.", - fields: [{ name: "", value: 2 }], - }), - ).toThrow(/basis.*2 sentences/i) + test("accepts exactly 400 basis characters", async () => { + expect(answerCommitModule.MAX_BASIS_CHARACTERS).toBe(400) + const basis = "x".repeat(400) + const payload = { basis, fields: [{ name: "", value: 2 }] } + const expected = `${basis}\n\n**Answer:** 2` + expect(AnswerCommit.renderAnswer(payload)).toBe(expected) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect((await tool.execute(payload, context)).output).toBe(expected) }) - test("paragraph breaks count as sentence boundaries", () => { - expect(() => - AnswerCommit.renderAnswer({ basis: "One.\n\nTwo.\n\nThree.", fields: [{ name: "", value: 2 }] }), - ).toThrow(/basis.*2 sentences/i) - }) + test.each(["x".repeat(401), ` ${"x".repeat(399)} `])( + "rejects 401 raw basis characters with the named bound", + async (basis) => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + for (const answer_only of [false, true]) { + const payload = { basis, answer_only, fields: [{ name: "", value: 2 }] } + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(/basis.*400 characters/i) + await rejects(tool.execute(payload, context), /basis.*400 characters/i) + } + }, + ) }) describe("renderAnswer structural validation", () => { @@ -671,12 +750,6 @@ describe("renderAnswer structural validation", () => { ["ragged rows", { rows: { columns: ["A", "B"], values: [[1, 2], [3]] } }, /rows.*1.*2.*cells/i], ["no columns", { rows: { columns: [], values: [[1]] } }, /rows.*columns/i], ["no rows", { rows: { columns: ["A"], values: [] } }, /rows.*values/i], - ["three sentences", { basis: "First. Second? Third!", fields: [{ name: "", value: 1 }] }, /basis.*2 sentences/i], - [ - "unpunctuated third sentence", - { basis: "First. Second! Third", fields: [{ name: "", value: 1 }] }, - /basis.*2 sentences/i, - ], ["negative precision", { fields: [{ name: "", value: 1.5, precision: -1 }] }, /precision/i], ["excessive precision", { fields: [{ name: "", value: 1.5, precision: 11 }] }, /precision/i], ["fractional precision", { fields: [{ name: "", value: 1.5, precision: 1.5 }] }, /precision/i], From db33d1c1d8f43342e1ae34784fe9217cda8fdc43 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 20:02:30 -0700 Subject: [PATCH 20/39] fix(analyst): one guard finalizes the committed answer on any non-abort exit; track the echo turn from the stream - `session/prompt.ts`: a single `finishCommittedAnswer` helper publishes the stored committed output through the start, delta, completion sequence, normalises the finish to `stop` and records the original cause in `metadata.answer_commit_echo_error`. The normal echo and the transport-error recovery use it, and a post-loop guard applies it when the request is about to end for any other non-abort, non-content-filter reason after a completed commit: a failing compaction summariser, an unrecoverable context overflow, a thrown preparation error. The guard re-checks for a newer real user request before publishing. Real request persistence emits `session.request.started`. - `cli/cmd/run.ts`: the `--max-turns` exemption no longer inspects the previous message. A completed `answer_commit` observed on the stream for the active request sets a flag that survives compaction and echo retries (so N+2 after a reactive overflow is still delivered); an explicit prompt, a synthetic follow-up, a soft-time reminder or another client's request clears it; a late completion from an older assistant cannot restore it. The remote message GET on the step-start path is gone. - `answer_commit`: every `rows.precision` key must name a column exactly; the error lists the unknown keys and the available columns. - Regression tests: failed compaction summariser, aborted and content-filtered compaction, unrecoverable overflow and thrown preparation error after a commit; `--max-turns 1` surviving reactive compaction with the echo on N+2; soft-time reminder clearing the exemption; precision key mismatch and exact match. Co-Authored-By: Claude Fable 5.1 --- .../src/altimate/tools/answer-commit.ts | 8 + packages/opencode/src/cli/cmd/run.ts | 44 ++-- packages/opencode/src/session/prompt.ts | 222 +++++++++++------- .../opencode/test/agent/answer-commit.test.ts | 89 ++++++- .../test/altimate/tools/answer-commit.test.ts | 26 ++ .../test/cli/run/budget-finalization.test.ts | 84 +++++++ 6 files changed, 363 insertions(+), 110 deletions(-) diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 9a66319c6c..942dd6cdee 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -67,6 +67,14 @@ const rows = z }) .strict() .superRefine((rows, ctx) => { + const unknown = Object.keys(rows.precision ?? {}).filter((key) => !rows.columns.includes(key)) + if (unknown.length) { + ctx.addIssue({ + code: "custom", + path: ["precision"], + message: `Unknown rows.precision keys: ${JSON.stringify(unknown)}; available columns: ${JSON.stringify(rows.columns)}`, + }) + } rows.values.forEach((row, index) => { if (row.length !== rows.columns.length) { ctx.addIssue({ diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 6129bb197f..0be9bcb912 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -725,6 +725,14 @@ You are speaking to a non-technical business executive. Follow these rules stric // altimate_change start — turn accounting + dual-attribution // termination state for this run (see run-accounting.ts). const accounting = RunAccounting.create() + let committedAnswer = false + let activeRequestID: string | undefined + const beginRequest = (messageID: string) => { + // Retries retain the request; compaction rows never emit a request-start event. + if (activeRequestID && messageID <= activeRequestID) return + activeRequestID = messageID + committedAnswer = false + } // altimate_change end // altimate_change start — idle-done fallback state (run-mode-only by // construction — this exists only in the run command). Thresholds are @@ -881,12 +889,15 @@ You are speaking to a non-technical business executive. Follow these rules stric // altimate_change start — save the reminder then interrupt only the active model request // The server applies the effort override before saving this user turn, // and defers interruption until executing tools have kept their results. + const messageID = MessageID.ascending() + beginRequest(messageID) // altimate_change end timeBudgetReminder = sdk.session // altimate_change start — carries soft control to both local and attached servers .prompt( { sessionID, + messageID, noReply: true, agent, model: args.model ? Provider.parseModel(args.model) : undefined, @@ -936,7 +947,6 @@ You are speaking to a non-technical business executive. Follow these rules stric // altimate_change start — max-turns budget enforcement (count kept in accounting) const maxTurns = options?.turnLimit ?? args.maxTurns let budgetAborted = false - let lastWorkMessageID: string | undefined // altimate_change end // altimate_change start — parameterized stream @@ -944,6 +954,15 @@ You are speaking to a non-technical business executive. Follow these rules stric if (options?.signal?.aborted) break await runTracing?.observe(event) if (options?.signal?.aborted) break + // altimate_change start - other attached clients can submit a new request within this stream + const requestEvent = event as { type: string; properties: { sessionID?: string; messageID?: string } } + if ( + requestEvent.type === "session.request.started" && + requestEvent.properties.sessionID === sessionID && + requestEvent.properties.messageID + ) + beginRequest(requestEvent.properties.messageID) + // altimate_change end // altimate_change start — shared SDKs omit fork-only run budget events const budgetEvent = event as { type: string; properties: { sessionID?: string; timestamp?: number } } if (budgetEvent.properties.sessionID === sessionID && typeof budgetEvent.properties.timestamp === "number") { @@ -995,6 +1014,13 @@ You are speaking to a non-technical business executive. Follow these rules stric // altimate_change start — tool steps cannot satisfy the final-answer check if (part.type === "tool" && !accounting.isCompactionStep(part.messageID)) lastToolStep = step + if ( + part.type === "tool" && part.tool === "answer_commit" && part.state.status === "completed" && + activeRequestID !== undefined && part.messageID > activeRequestID + ) { + // A tool from an interrupted older request may finish after its replacement was sent. + committedAnswer = true + } // altimate_change end // altimate_change start — feed every part event through the @@ -1048,21 +1074,9 @@ You are speaking to a non-technical business executive. Follow these rules stric // the owning message's agent is resolved via the message.updated lookup // above, so compacting models are not differentially charged turns. const counted = accounting.onStepStart(part.messageID) - const previousMessageID = lastWorkMessageID - if (counted) lastWorkMessageID = part.messageID - let committedEcho = false - if (counted && maxTurns && accounting.turnCount === maxTurns + 1 && previousMessageID) { - // Inspect persisted parts: a later failed commit must revoke an earlier success. - const previous = await sdk.session.message( - { sessionID, messageID: previousMessageID }, - { signal: options?.signal ?? AbortSignal.timeout(5_000) }, - ).catch(() => undefined) - const commit = previous?.data?.parts.findLast((part) => part.type === "tool" && part.tool === "answer_commit") - committedEcho = commit?.type === "tool" && commit.state.status === "completed" - } // altimate_change start — the time-budget abort already owns the interrupted work phase if ( - counted && maxTurns && accounting.turnCount > maxTurns && !committedEcho && !budgetAborted && + counted && maxTurns && accounting.turnCount > maxTurns && !committedAnswer && !budgetAborted && (!timeExhausted() || options?.turnLimit !== undefined) ) { stopTimeBudget() @@ -1534,6 +1548,7 @@ You are speaking to a non-technical business executive. Follow these rules stric // server whether that message landed, and only re-send when it did not. const sendMessageID = MessageID.ascending() const send = () => { + beginRequest(sendMessageID) // altimate_change start — start once at prompt dispatch, never reset on retry or child work startWorkClock() // altimate_change end @@ -1653,6 +1668,7 @@ You are speaking to a non-technical business executive. Follow these rules stric }) let streamFailed = false const messageID = MessageID.ascending() + beginRequest(messageID) const promptPromise = (async (): Promise => { // The previous turn may have published idle just before releasing its // session lock. Retry that narrow race, but only after definitive diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 166bf3c749..ad59f43c64 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -24,6 +24,9 @@ import { NudgeArbiter } from "./nudge" import { SessionTermination } from "./termination" import { Instance } from "../project/instance" import { Bus } from "../bus" +// altimate_change start - distinguish submitted requests from compaction-created user rows in the stream +import { BusEvent } from "../bus/bus-event" +// altimate_change end import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" import { InstructionPrompt } from "./instruction" @@ -126,6 +129,12 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) + // altimate_change start - request identity is independent of compaction message adjacency + export const RequestStarted = BusEvent.define( + "session.request.started", + z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), + ) + // altimate_change end // altimate_change start — how long a turn will wait for the workspace skill // sync before proceeding without it. See the block in `prompt`. @@ -819,6 +828,99 @@ export namespace SessionPrompt { // altimate_change start - retain the exact committed text through automatic compaction let committedAnswer: { userID: MessageID; output: string } | undefined const compactionContinuations = new Set() + const captureCommittedAnswer = (user: MessageV2.User, assistant: MessageV2.Assistant, parts: MessageV2.Part[]) => { + if (assistant.parentID !== user.id || assistant.summary || !parts.some((part) => part.type === "tool")) return + const commit = parts.findLast((part) => part.type === "tool" && part.tool === "answer_commit") + committedAnswer = + user.agent === "analyst" && + (user.format?.type ?? "text") === "text" && + assistant.agent === "analyst" && + commit?.type === "tool" && + commit.state.status === "completed" + ? { + userID: user.id, + // Completed-tool metadata retains the full answer through the per-result output cap. + output: + typeof commit.state.metadata.committed_text === "string" + ? commit.state.metadata.committed_text + : commit.state.output, + } + : undefined + } + const publishText = async (parts: MessageV2.TextPart[]) => { + for (const part of parts) { + await Session.updatePart({ ...part, text: "", time: part.time && { start: part.time.start } }) + await Session.updatePartDelta({ + sessionID, + messageID: part.messageID, + partID: part.id, + field: "text", + delta: part.text, + }) + await Session.updatePart(part) + } + } + const finishCommittedAnswer = async ( + message: MessageV2.Assistant, + deferredText: MessageV2.TextPart[] = [], + error = message.error, + ) => { + if (!committedAnswer || abort.aborted || message.id <= committedAnswer.userID) return false + // Keep aborts and HTTP policy rejections fatal even if a later operation throws another error. + if ( + message.finish === "content-filter" || + [message.error, error].some( + (cause) => + cause?.name === "MessageAbortedError" || + cause?.name === "ContentFilterError" || + (cause && + ["APIError", "UnknownError", "ProviderAuthError"].includes(cause.name) && + !MessageV2.isEchoTransportError(cause)), + ) + ) + return false + for (const msg of MessageV2.stream(sessionID)) { + if (msg.info.id <= committedAnswer.userID) break + if ( + msg.info.role === "user" && + !msg.parts.some((part) => part.type === "compaction") && + !compactionContinuations.has(msg.info.id) + ) { + committedAnswer = undefined + return false + } + } + const storedText = MessageV2.parts(message.id).filter((part) => part.type === "text") + const first = deferredText[0] ?? storedText[0] + const part: MessageV2.TextPart = { + id: first?.id ?? PartID.ascending(), + sessionID, + messageID: message.id, + type: "text", + text: committedAnswer.output, + time: { start: first?.time?.start ?? message.time.created, end: Date.now() }, + } + for (const old of storedText) { + if (old.id !== part.id) await Session.removePart({ sessionID, messageID: message.id, partID: old.id }) + } + const cause = error ?? (message.finish !== "stop" ? { finish: message.finish ?? "unknown" } : undefined) + if (cause) message.metadata = { ...message.metadata, answer_commit_echo_error: cause } + delete message.error + // A failed summariser becomes the exiting answer, so clients must no longer hide it as compaction machinery. + if (message.summary) { + delete message.summary + message.agent = "analyst" + message.mode = "analyst" + } + message.finish = "stop" + message.time.completed = Date.now() + await Session.updateMessage(message) + await publishText([part]) + committedAnswer = undefined + return true + } + let loopFailure: { error: unknown } | undefined + try { // altimate_change end while (true) { // altimate_change start — SessionStatus.set became async in v1.4.0; await so busy state flushes before LLM call @@ -882,30 +984,13 @@ export namespace SessionPrompt { ) ) committedAnswer = undefined - if ( - commitRequest?.info.role === "user" && - lastAssistant?.parentID === commitRequest.info.id && - !lastAssistant.summary - ) { - const parts = msgs.find((msg) => msg.info.id === lastAssistant.id)?.parts - const commit = parts?.findLast((part) => part.type === "tool" && part.tool === "answer_commit") - // An echo that overflowed has no finish or tool part; keep its committed output for the retry. - if (parts?.some((part) => part.type === "tool") || lastAssistant.finish) - committedAnswer = - commitRequest.info.agent === "analyst" && - (commitRequest.info.format?.type ?? "text") === "text" && - lastAssistant.agent === "analyst" && - commit?.type === "tool" && - commit.state.status === "completed" - ? { - userID: commitRequest.info.id, - // Completed-tool metadata retains the full answer through the per-result output cap. - output: - typeof commit.state.metadata.committed_text === "string" - ? commit.state.metadata.committed_text - : commit.state.output, - } - : undefined + if (commitRequest?.info.role === "user" && lastAssistant) { + // Text-only echoes and compaction summaries retain the commit until delivery or a new request. + captureCommittedAnswer( + commitRequest.info, + lastAssistant, + msgs.find((msg) => msg.info.id === lastAssistant.id)?.parts ?? [], + ) } // altimate_change end // altimate_change start — client trace for this step. A user message carrying a compaction @@ -1858,69 +1943,13 @@ export namespace SessionPrompt { }) // altimate_change start - finalize buffered echoes before publishing any text to clients - const echoTransportError = - echoCommittedAnswer && - !abort.aborted && - processor.message.finish !== "content-filter" && - MessageV2.isEchoTransportError(processor.message.error) - ? processor.message.error - : undefined + const stepParts = MessageV2.parts(processor.message.id) + // Capture before post-processing can fail or request compaction. + captureCommittedAnswer(lastUser, processor.message, stepParts) const echoFinished = - echoCommittedAnswer && - (echoTransportError || - ((processor.message.finish === "stop" || - processor.message.finish === "length" || - processor.message.finish === "other" || - processor.message.finish === "unknown") && - !processor.message.error)) && - !abort.aborted - let textParts = deferredText - if (echoFinished && committedAnswer) { - // Collapse even a multipart echo into one authoritative completed text part. - textParts = [ - { - ...(textParts[0] ?? { - id: PartID.ascending(), - sessionID, - messageID: processor.message.id, - type: "text" as const, - }), - text: committedAnswer.output, - time: { - start: textParts[0]?.time?.start ?? processor.message.time.created, - end: Date.now(), - }, - }, - ] - if (echoTransportError) { - processor.message.metadata = { - ...processor.message.metadata, - answer_commit_echo_error: echoTransportError, - } - delete processor.message.error - } - // "unknown" is an unmapped provider reason; the loop treats it as unfinished, so a - // delivered committed answer must end as a normal stop. - if (processor.message.finish === "length" || processor.message.finish === "unknown" || echoTransportError) { - processor.message.finish = "stop" - await Session.updateMessage(processor.message) - } - } - // Normal start -> delta -> completion delivery serves both delta-only ACP and - // run's completed-part output collector. Error-free "other" echoes use committed text - // while retaining their finish; "unknown" ones use it and finish as stop; aborted, - // content-filtered, and unrecovered errors retain original text/timing. - for (const part of textParts) { - await Session.updatePart({ ...part, text: "", time: part.time && { start: part.time.start } }) - await Session.updatePartDelta({ - sessionID, - messageID: part.messageID, - partID: part.id, - field: "text", - delta: part.text, - }) - await Session.updatePart(part) - } + echoCommittedAnswer && result !== "compact" && (await finishCommittedAnswer(processor.message, deferredText)) + // Abort and content-filter exits retain their original buffered text and timing. + if (!echoFinished) await publishText(deferredText) // An input overflow has no finish, so it still follows the existing compaction retry below. // altimate_change end // If structured output was captured, save it and exit immediately @@ -1951,7 +1980,6 @@ export namespace SessionPrompt { sessionTotalCost += processor.message.cost ?? 0 const t = processor.message.tokens sessionTotalTokens += t.input + t.output + t.reasoning + t.cache.read + t.cache.write - const stepParts = await MessageV2.parts(processor.message.id) toolCallCount += stepParts.filter((p) => p.type === "tool").length if (processor.message.error) sessionHadError = true // altimate_change start — capture last message error for agent_outcome reason @@ -2266,6 +2294,23 @@ export namespace SessionPrompt { } continue } + // altimate_change start - one request-exit guard covers compaction stops and failures outside the processor + } catch (error) { + loopFailure = { error } + } + let recovered = false + if (committedAnswer) { + const exiting = [...MessageV2.stream(sessionID)].find((msg) => msg.info.role === "assistant") + if (exiting?.info.role === "assistant") { + recovered = await finishCommittedAnswer( + exiting.info, + [], + loopFailure ? MessageV2.fromError(loopFailure.error, { providerID: exiting.info.providerID }) : exiting.info.error, + ) + } + } + if (loopFailure && !recovered) throw loopFailure.error + // altimate_change end // altimate_change start — the generation-scoped disposer publishes the // sole normal idle transition after removing this loop from state. Abort // and processor-error paths may already be idle; the disposer detects that. @@ -3185,6 +3230,9 @@ export namespace SessionPrompt { for (const part of parts) { await Session.updatePart(part) } + // altimate_change start - internal compaction continuations do not pass through this request boundary + await Bus.publish(RequestStarted, { sessionID: input.sessionID, messageID: info.id }) + // altimate_change end return { info, diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 90da84f7ca..4fb27d667c 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -358,6 +358,11 @@ for (const scenario of [ "echo-mismatch", "echo-no-text", "echo-transport-error", + "compaction-error", + "compaction-aborted", + "compaction-content-filter", + "echo-overflow-disabled", + "echo-preparation-error", "echo-aborted", "echo-content-filter", "echo-http-content-filter", @@ -377,16 +382,22 @@ for (const scenario of [ const structured = scenario.startsWith("json_schema") const priorCommit = scenario === "json_schema-history" const tinyCap = scenario === "echo-capped" || scenario === "echo-under-cap" + const compacting = scenario.startsWith("compaction-") const name = { text: "text requests offer answer_commit and immediately echo a successful commit without a step limit", "echo-length": "a length-limited echo is replaced with the stored committed output verbatim", "echo-mismatch": "a mismatched echo delivers the committed output exactly once to run output and ACP", "echo-no-text": "a stop echo without text publishes the committed output as a completed timed text part", "echo-transport-error": "a non-retryable echo transport failure publishes the committed output and records metadata", + "compaction-error": "a failed compaction summariser after a commit publishes the committed output and records the cause", + "compaction-aborted": "an aborted compaction after a commit retains its fatal error without publishing the committed output", + "compaction-content-filter": "a content-filtered compaction after a commit retains its fatal error without publishing the committed output", + "echo-overflow-disabled": "an unrecoverable context overflow after a commit publishes the committed output and records the cause", + "echo-preparation-error": "a thrown echo preparation error after a commit publishes the committed output and records the cause", "echo-aborted": "an aborted echo retains its fatal error without publishing the committed output", "echo-content-filter": "a content-filtered echo retains its fatal error and original text", "echo-http-content-filter": "an HTTP content-filtered echo retains its fatal error without publishing the committed output", - "echo-other": "an echo with an other finish keeps its finish and delivers the committed output exactly once", + "echo-other": "an echo with an other finish ends as stop and records the original cause", "echo-exact-length": "an exact echo with a length finish ends normally with stop", "echo-capped": "a dispatch-capped commit preserves the full rendered answer in the final assistant text", "echo-under-cap": "a commit below the dispatch cap preserves the rendered answer in the final assistant text", @@ -453,17 +464,22 @@ for (const scenario of [ if (requests.length > (priorCommit ? 4 : scenario === "user-during-echo" ? 3 : 2)) throw new Error("unexpected extra generation") const step = priorCommit && requests.length > 2 ? requests.length - 2 : requests.length - if (step === 2 && (scenario === "echo-transport-error" || scenario === "echo-http-content-filter")) { + if ( + step === 2 && + ["echo-transport-error", "echo-http-content-filter", "compaction-error", "compaction-content-filter", "echo-overflow-disabled"].includes(scenario) + ) { const error = - scenario === "echo-http-content-filter" + scenario === "echo-http-content-filter" || scenario === "compaction-content-filter" ? { code: "content_filter", message: "Echo request blocked by content policy" } - : { message: "Echo transport rejected the request" } + : scenario === "echo-overflow-disabled" + ? { code: "context_length_exceeded", message: "maximum context length exceeded" } + : { message: compacting ? "Compaction summariser rejected the request" : "Echo transport rejected the request" } return new Response(JSON.stringify({ error }), { status: 400, headers: { "Content-Type": "application/json" }, }) } - if (step === 2 && scenario === "echo-aborted") { + if (step === 2 && (scenario === "echo-aborted" || scenario === "compaction-aborted")) { throw new DOMException("Echo request aborted", "AbortError") } if ( @@ -481,6 +497,7 @@ for (const scenario of [ ? reply().tool("answer_commit", args).tool("answer_commit", {}).item() : reply() .tool("answer_commit", scenario === "failed" ? {} : args) + .usage({ input: compacting ? 95000 : 0, output: 1 }) .item() : scenario === "echo-no-text" ? reply().stop().item() @@ -542,6 +559,8 @@ for (const scenario of [ const languageSpy = spyOn(Provider, "getLanguage").mockResolvedValue(language) const toModelMessages = MessageV2.toModelMessages const historySpy = spyOn(MessageV2, "toModelMessages").mockImplementation(async (...input) => { + if (scenario === "echo-preparation-error" && requests.length === 1) + throw new Error("Echo preparation failed") const messages = await toModelMessages(...input) if (scenario === "queued-user" && requests.length === 1 && !queuedUserID) await insertUser() return messages @@ -551,7 +570,15 @@ for (const scenario of [ const textPartIDs = new Set() const textDeltas: { messageID: MessageID; text: string }[] = [] const sessionErrors: unknown[] = [] + const requestStarts: string[] = [] const collect = ({ payload: event }: GlobalEvent) => { + const request = event as { type: string; properties: { sessionID?: string; messageID?: string } } + if ( + request.type === "session.request.started" && + request.properties.sessionID === chat.id && + request.properties.messageID + ) + requestStarts.push(request.properties.messageID) if (event.type === "session.error" && event.properties.sessionID === chat.id) { sessionErrors.push(event.properties.error) } @@ -625,13 +652,19 @@ for (const scenario of [ : {}), }) if ( - scenario === "echo-aborted" || scenario === "echo-content-filter" || scenario === "echo-http-content-filter" + scenario === "echo-aborted" || + scenario === "echo-content-filter" || + scenario === "echo-http-content-filter" || + scenario === "compaction-aborted" || + scenario === "compaction-content-filter" ) { expect(result.info).toHaveProperty( "error.name", - scenario === "echo-aborted" + scenario === "echo-aborted" || scenario === "compaction-aborted" ? "MessageAbortedError" - : scenario === "echo-http-content-filter" ? "APIError" : "ContentFilterError", + : scenario === "echo-http-content-filter" || scenario === "compaction-content-filter" + ? "APIError" + : "ContentFilterError", ) expect(result.info).not.toHaveProperty("metadata.answer_commit_echo_error") expect(result.info).not.toHaveProperty("finish", "stop") @@ -647,6 +680,42 @@ for (const scenario of [ return } expect(result.info).not.toHaveProperty("error") + if ( + scenario === "compaction-error" || scenario === "echo-overflow-disabled" || scenario === "echo-preparation-error" + ) { + const cause = + scenario === "compaction-error" + ? "Compaction summariser rejected the request" + : scenario === "echo-overflow-disabled" ? "maximum context length exceeded" : "Echo preparation failed" + expect(result.info).toHaveProperty("metadata.answer_commit_echo_error.data.message", cause) + expect(result.info).toHaveProperty("finish", "stop") + expect(result.info).not.toHaveProperty("summary", true) + expect(result.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual([renderedAnswer]) + expect(textUpdates.filter((part) => part.messageID === result.info.id).map((part) => part.text)).toEqual([ + "", renderedAnswer, + ]) + expect(textDeltas.filter((part) => part.messageID === result.info.id).map((part) => part.text)).toEqual([ + renderedAnswer, + ]) + expect(output.content()).toBe(renderedAnswer) + expect(requests).toHaveLength(scenario === "echo-preparation-error" ? 1 : 2) + const history = yield* sessions.messages({ sessionID: chat.id }) + if (compacting) { + expect(JSON.stringify(requests[1].messages)).toContain("Provide a detailed prompt for continuing") + expect(JSON.stringify(requests[1].messages)).not.toContain(echoInstruction) + // Only the original request and the compaction marker exist: no continuation was created. + expect(history.filter((message) => message.info.role === "user")).toHaveLength(2) + const request = history.find( + (message) => message.info.role === "user" && !message.parts.some((part) => part.type === "compaction"), + ) + if (!request) throw new Error("missing original request") + expect(requestStarts).toEqual([request.info.id]) + } + const persisted = history.find((message) => message.info.id === result.info.id) + expect(persisted?.info).toHaveProperty("metadata.answer_commit_echo_error.data.message", cause) + expect(persisted?.info).not.toHaveProperty("error") + return + } if (scenario === "echo-transport-error") { expect(sessionErrors).toEqual([]) expect(result.info).toHaveProperty( @@ -715,7 +784,8 @@ for (const scenario of [ expect(last.content).toBe(echoInstruction) } const expectedText = renderedAnswer - expect(result.info).toHaveProperty("finish", scenario === "echo-other" ? "other" : "stop") + expect(result.info).toHaveProperty("finish", "stop") + if (scenario === "echo-other") expect(result.info).toHaveProperty("metadata.answer_commit_echo_error.finish", "other") expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: expectedText })) if (scenario === "echo-no-text") { const part = result.parts.find((part) => part.type === "text") @@ -773,6 +843,7 @@ for (const scenario of [ config: { snapshot: false, learn: { enabled: false }, + ...(scenario === "echo-overflow-disabled" ? { compaction: { auto: false } } : {}), ...(tinyCap ? { tool_output: { dispatch_max_tokens: 100 } } : {}), agent: { analyst: { permission: { StructuredOutput: "allow" } } }, provider: { diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index 790121a4de..b3a691a72d 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -328,6 +328,32 @@ describe("answer_commit display precision", () => { const tool = await initTool(AnswerCommit.AnswerCommitTool) expect((await tool.execute(payload, context)).output).toBe(expected) }) + + test.each([{ revenue: 2 }, { revenue: 2, Missing: 1 }])( + "row precision rejects unknown column names %j and lists available columns", + async (precision) => { + const payload = { + basis: recordBasis, + rows: { columns: ["Revenue"], values: [[5]], precision }, + } + const error = `Unknown rows.precision keys: ${JSON.stringify(Object.keys(precision))}; available columns: ["Revenue"]` + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(error) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + await rejects(tool.execute(payload, context), (cause) => cause instanceof Error && cause.message.includes(error)) + }, + ) + + test("row precision accepts exact column names", async () => { + const payload = { + basis: recordBasis, + answer_only: true, + rows: { columns: ["Revenue"], values: [[5]], precision: { Revenue: 2 } }, + } + const expected = "| Revenue |\n|---|\n| 5.00 |" + expect(AnswerCommit.renderAnswer(payload)).toBe(expected) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect((await tool.execute(payload, context)).output).toBe(expected) + }) }) describe("answer_commit rendered length limit", () => { diff --git a/packages/opencode/test/cli/run/budget-finalization.test.ts b/packages/opencode/test/cli/run/budget-finalization.test.ts index 23ca1151ca..2695a3bd21 100644 --- a/packages/opencode/test/cli/run/budget-finalization.test.ts +++ b/packages/opencode/test/cli/run/budget-finalization.test.ts @@ -245,6 +245,90 @@ describe("opencode run: budget finalization subprocess", () => { }), 90_000, ) + cliIt.concurrent( + "answer_commit on --max-turns 1 survives reactive compaction and publishes the retried echo on N+2", + ({ llm, opencode }) => + Effect.gen(function* () { + const committed = "Committed revenue after compaction: 42" + yield* llm.tool("answer_commit", { + fields: [{ name: "", value: committed }], + answer_only: true, + basis: "Summed orders.amount over all orders at order grain.", + }) + // A streaming overflow begins the N+1 step before compaction retries it. + // The compatible provider exposes the error's message to the processor. + yield* llm.push({ + type: "sse", + head: [], + tail: [{ error: { message: JSON.stringify({ type: "error", error: { code: "context_length_exceeded" } }) } }], + }) + yield* llm.text("The revenue answer was committed; return the stored answer.") + yield* llm.text("A different provider echo must be replaced after compaction.") + yield* llm.textMatch(({ body }) => isFinalization(body), "The retried echo was aborted by the turn limit.") + + const result = yield* opencode.run("Commit the revenue answer.", { + agent: "analyst", + format: "json", + extraArgs: ["--max-turns", "1"], + env: { ALTIMATE_ANSWER_COMMIT: "1" }, + timeoutMs: 60_000, + bunRun: true, + }) + opencode.expectExit(result, 0) + const events = opencode.parseJsonEvents(result.stdout) + expect(events.filter((event) => event.type === "budget_exhausted")).toHaveLength(0) + expect(events.filter((event) => event.type === "silent_turn_reply")).toHaveLength(0) + expect(events.filter((event) => event.type === "step_start")).toHaveLength(4) + const texts = events.filter((event) => event.type === "text").map((event) => (event.part as { text: string }).text) + expect(texts.at(-1)).toBe(committed) + expect(texts.filter((text) => text === committed)).toHaveLength(1) + expect(events.filter((event) => event.type === "termination")).toMatchObject([ + { why_model_stopped: "stop", why_harness_stopped: "none" }, + ]) + const requests = (yield* llm.inputs).filter( + (body) => !JSON.stringify(body).includes("Generate a title for this conversation"), + ) + expect(requests).toHaveLength(4) + expect(JSON.stringify(requests[2])).toContain("Provide a detailed prompt for continuing") + expect(requests[3]!.tools ?? []).toEqual([]) + expect(requests.filter(isFinalization)).toHaveLength(0) + }), + 90_000, + ) + cliIt.concurrent( + "a soft-time reminder clears the committed answer turn exemption for its new request", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.tool("answer_commit", { + fields: [{ name: "", value: "The original committed answer." }], + answer_only: true, + basis: "Summed orders.amount over all orders at order grain.", + }) + yield* llm.push(reply().reason("Waiting to publish the committed echo.").hang()) + yield* llm.textMatch(({ body }) => isFinalization(body), "The new request exhausted its turn budget.") + yield* llm.pushMatch(({ body }) => !isFinalization(body), reply().reason("Working on the new request.").hang()) + + const result = yield* opencode.run("Commit the revenue answer.", { + agent: "analyst", + format: "json", + extraArgs: ["--max-turns", "1", "--time-budget", "10"], + env: { ALTIMATE_ANSWER_COMMIT: "1" }, + timeoutMs: 60_000, + bunRun: true, + }) + opencode.expectExit(result, 1) + const events = opencode.parseJsonEvents(result.stdout) + expect(events.filter((event) => event.type === "time_budget_warning")).toHaveLength(1) + expect(events.filter((event) => event.type === "budget_exhausted")).toMatchObject([{ limit: 1 }]) + const budgetIndex = events.findIndex((event) => event.type === "budget_exhausted") + expect(events.slice(0, budgetIndex).filter((event) => event.type === "step_start")).toHaveLength(2) + expect(events.filter((event) => event.type === "text").map((event) => (event.part as { text: string }).text)).toEqual([ + "The new request exhausted its turn budget.", + ]) + expect((yield* llm.inputs).filter(isFinalization)).toHaveLength(1) + }), + 90_000, + ) // altimate_change end // altimate_change start — exhausted budgets must disable execution even if the model calls a tool for (const budget of ["turn", "time"] as const) { From ca9d90a5c87f0c98532cbb75d1011d5be930140f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 20:27:00 -0700 Subject: [PATCH 21/39] fix(analyst): recover the echo only from transient transport failures; never persist a transient fatal error - Recovery is structural, not phrase-based: an echo failure is recoverable only when it is an `APIError` with HTTP 5xx, 429 or 408, or a statusless network/stream failure (connection reset, socket timeout, idle watchdog, SDK retryable request failure). Any 4xx, any structured policy, content, safety or moderation code in the body, `ProviderAuthError`, and local errors raised before the provider request stay fatal. Error normalisation preserves the structured code ahead of body truncation and keeps a policy decision with overflow-looking text fatal. - A context overflow on the echo step after a completed commit is recoverable because the committed text already exists: the processor records the cause as `metadata.answer_commit_echo_error` instead of setting `error` or publishing `session.error`, and the loop publishes the committed text directly. This fixes the headless `--max-turns 1` scenario where the overflow was published as a fatal event before the recovery guard ran and the run exited 1. - The `--max-turns` exemption is cleared at every distinct persisted real-request boundary, including a request submitted by another attached client, and the step-start path never awaits a session fetch. - `rows.precision` with duplicate column labels is rejected; duplicates without precision render unchanged. - Tests: 503 and socket reset recover without fatal events; 400 structured policy denial and 403 stay fatal; local pre-request failure stays fatal; overflow on the echo publishes the committed text without fatal events; policy code beyond the body limit survives normalisation; over-budget step aborts without any session fetch; new attached-session request clears the exemption; `--max-turns 1` publishes the committed output after an echo overflow; unique column labels required for precision. Co-Authored-By: Claude Fable 5.1 --- .../src/altimate/tools/answer-commit.ts | 7 + packages/opencode/src/cli/cmd/run.ts | 29 +- packages/opencode/src/session/message-v2.ts | 81 +++++- packages/opencode/src/session/processor.ts | 34 +-- packages/opencode/src/session/prompt.ts | 4 +- .../opencode/test/agent/answer-commit.test.ts | 190 +++++++++++-- .../test/altimate/tools/answer-commit.test.ts | 23 ++ .../test/cli/run/budget-finalization.test.ts | 251 +++++++++++++++++- packages/opencode/test/session/prompt.test.ts | 18 +- 9 files changed, 567 insertions(+), 70 deletions(-) diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 942dd6cdee..0b612e1435 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -67,6 +67,13 @@ const rows = z }) .strict() .superRefine((rows, ctx) => { + if (rows.precision !== undefined && new Set(rows.columns).size !== rows.columns.length) { + ctx.addIssue({ + code: "custom", + path: ["precision"], + message: "rows.precision requires unique column labels", + }) + } const unknown = Object.keys(rows.precision ?? {}).filter((key) => !rows.columns.includes(key)) if (unknown.length) { ctx.addIssue({ diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0be9bcb912..f616368305 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -727,9 +727,15 @@ You are speaking to a non-technical business executive. Follow these rules stric const accounting = RunAccounting.create() let committedAnswer = false let activeRequestID: string | undefined + const assistantRequests = new Map() + const userRequests = new Map() + const seenRequests = new Set() const beginRequest = (messageID: string) => { - // Retries retain the request; compaction rows never emit a request-start event. - if (activeRequestID && messageID <= activeRequestID) return + // The persisted request boundary includes requests from other attached clients. + // Retries retain their ID; internal synthetic/compaction rows do not emit this event. + if (seenRequests.has(messageID)) return + seenRequests.add(messageID) + userRequests.set(messageID, messageID) activeRequestID = messageID committedAnswer = false } @@ -976,12 +982,27 @@ You are speaking to a non-technical business executive. Follow these rules stric // step-start parts (which carry only messageID/sessionID) can be attributed. // The assistant message row is persisted — and this event published — before // its first step-start part streams, so the lookup is populated in time. + if ( + event.type === "message.updated" && + event.properties.info.role === "user" && + event.properties.info.sessionID === sessionID && + !userRequests.has(event.properties.info.id) + ) { + // Compaction-created parent rows inherit the current request; a real + // request's subsequent persisted boundary replaces this with its own ID. + userRequests.set(event.properties.info.id, activeRequestID) + } if ( event.type === "message.updated" && event.properties.info.role === "assistant" && event.properties.info.sessionID === sessionID ) { accounting.onAssistantMessage(event.properties.info) + // Keep the request that owned the assistant's creation, even if an older + // tool finishes after another attached client persists a new request. + if (!assistantRequests.has(event.properties.info.id)) { + assistantRequests.set(event.properties.info.id, userRequests.get(event.properties.info.parentID)) + } assistantStarted = true // Enrich only from this run's assistant, independently of presentation. const info = event.properties.info @@ -1016,9 +1037,9 @@ You are speaking to a non-technical business executive. Follow these rules stric if (part.type === "tool" && !accounting.isCompactionStep(part.messageID)) lastToolStep = step if ( part.type === "tool" && part.tool === "answer_commit" && part.state.status === "completed" && - activeRequestID !== undefined && part.messageID > activeRequestID + activeRequestID !== undefined && assistantRequests.get(part.messageID) === activeRequestID ) { - // A tool from an interrupted older request may finish after its replacement was sent. + // Only this request's commit can exempt its echo from the work-turn limit. committedAnswer = true } // altimate_change end diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e6ea62d77f..6557e8b893 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -519,12 +519,53 @@ export namespace MessageV2 { }) export type Assistant = z.infer - // altimate_change start — only provider/transport failures permit committed-answer echo recovery + // altimate_change start — recover committed echoes only from transient transport failures + const echoNetworkCodes = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "ESOCKETTIMEDOUT", + "EPIPE", + "ENOTFOUND", + "EAI_AGAIN", + "UND_ERR_SOCKET", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", + ]) + + function echoPolicyCode(responseBody: string | undefined): string | undefined { + if (!responseBody) return undefined + const find = (value: unknown): string | undefined => { + if (!value || typeof value !== "object") return undefined + for (const [key, child] of Object.entries(value)) { + if (key === "code" && typeof child === "string" && /policy|content|safety|moderation/i.test(child)) return child + const nested = find(child) + if (nested) return nested + } + } + try { + return find(JSON.parse(responseBody)) + } catch { + return undefined + } + } + export function isEchoTransportError(error: Assistant["error"]): boolean { - if (error?.name !== "APIError" && error?.name !== "UnknownError" && error?.name !== "ProviderAuthError") return false - // HTTP policy rejections can arrive as APIError rather than a content-filter finish. - const detail = error.data.message + (error.name === "APIError" ? `\n${error.data.responseBody ?? ""}` : "") - return !/content[-_\s]?(?:filter|policy)|safety|guardrail[_\s-]intervened|ResponsibleAIPolicyViolation/i.test(detail) + if (error?.name !== "APIError") return false + const { statusCode, responseBody, metadata, isRetryable } = error.data + // Provider decisions can be wrapped by a gateway with a transient HTTP status. + // Inspect structured codes, never prose in a message or response body. + if (echoPolicyCode(responseBody) || /policy|content|safety|moderation/i.test(metadata?.code ?? "")) return false + if (statusCode !== undefined) + return (statusCode >= 500 && statusCode < 600) || statusCode === 429 || statusCode === 408 + const code = metadata?.code + return ( + (code !== undefined && + (echoNetworkCodes.has(code) || + ["ZlibError", "ProviderHeaderTimeoutError", "ProviderStreamIdleTimeoutError"].includes(code))) || + (isRetryable && metadata?.url !== undefined && !responseBody) + ) } // altimate_change end export const Info = z.discriminatedUnion("role", [User, Assistant]).meta({ @@ -1154,10 +1195,14 @@ export namespace MessageV2 { }, { cause: e }, ).toObject() - case (e as SystemError)?.code === "ECONNRESET": + // altimate_change start — retain structured network codes for echo recovery + case echoNetworkCodes.has((e as SystemError)?.code ?? ""): + // altimate_change end return new MessageV2.APIError( { - message: "Connection reset by server", + // altimate_change start — preserve the established reset message and other transport details + message: (e as SystemError).code === "ECONNRESET" ? "Connection reset by server" : errorMessage(e), + // altimate_change end isRetryable: true, metadata: { code: (e as SystemError).code ?? "", @@ -1213,6 +1258,22 @@ export namespace MessageV2 { providerID: ctx.providerID, error: e, }) + // altimate_change start — retain policy decisions before response truncation or overflow detection can hide them + const policyCode = echoPolicyCode(e.responseBody) + if (policyCode) { + return new MessageV2.APIError( + { + message: parsed.message, + statusCode: e.statusCode, + isRetryable: parsed.type === "api_error" ? parsed.isRetryable : e.isRetryable, + responseHeaders: parsed.type === "api_error" ? parsed.responseHeaders : e.responseHeaders, + responseBody: parsed.responseBody, + metadata: { ...(parsed.type === "api_error" ? parsed.metadata : undefined), code: policyCode }, + }, + { cause: e }, + ).toObject() + } + // altimate_change end if (parsed.type === "context_overflow") { return new MessageV2.ContextOverflowError( { @@ -1230,7 +1291,11 @@ export namespace MessageV2 { isRetryable: parsed.isRetryable, responseHeaders: parsed.responseHeaders, responseBody: parsed.responseBody, - metadata: parsed.metadata, + // altimate_change start — SDK-wrapped socket failures retain their structural transport code + metadata: echoNetworkCodes.has((e.cause as SystemError)?.code ?? "") + ? { ...parsed.metadata, code: (e.cause as SystemError).code ?? "" } + : parsed.metadata, + // altimate_change end }, { cause: e }, ).toObject() diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 75956773a7..5ddbe6478b 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1261,7 +1261,19 @@ export namespace SessionProcessor { // altimate_change end }) const error = MessageV2.fromError(e, { providerID: input.model.providerID }) - if (MessageV2.ContextOverflowError.isInstance(error)) { + // altimate_change start — a buffered echo already has its answer; persist the cause without a fatal event + if ( + deferText && + !input.abort.aborted && + input.assistantMessage.finish !== "content-filter" && + (MessageV2.isEchoTransportError(error) || MessageV2.ContextOverflowError.isInstance(error)) + ) { + input.assistantMessage.metadata = { + ...input.assistantMessage.metadata, + answer_commit_echo_error: error, + } + } else if (MessageV2.ContextOverflowError.isInstance(error)) { + // altimate_change end if ((await Config.get()).compaction?.auto === false && !input.assistantMessage.summary) { // altimate_change start — upstream_fix: honor // compaction.auto=false on reactive provider overflow. @@ -1309,25 +1321,15 @@ export namespace SessionProcessor { }) } // altimate_change end - input.assistantMessage.error = error // altimate_change start — order terminal error before idle publication // Publish the error before idle. The run harness drains this // generation through idle before opening a challenge stream; an // unawaited publish can otherwise arrive on that fresh stream. - // The prompt loop recovers a buffered committed-answer echo and records - // its transport error as message metadata before publishing the stored text. - // Publishing a fatal event here would permanently fail headless run accounting. - if ( - !deferText || - input.abort.aborted || - input.assistantMessage.finish === "content-filter" || - !MessageV2.isEchoTransportError(error) - ) { - await Bus.publish(Session.Event.Error, { - sessionID: input.assistantMessage.sessionID, - error: input.assistantMessage.error, - }) - } + input.assistantMessage.error = error + await Bus.publish(Session.Event.Error, { + sessionID: input.assistantMessage.sessionID, + error: input.assistantMessage.error, + }) // altimate_change end // altimate_change start — telemetry for unhandled streaming errors (non-retry, non-overflow) // Covers: MessageAbortedError (Stop/dispose), UnknownError (SSE chunk timeout), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ad59f43c64..798e8e4ed9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -903,7 +903,9 @@ export namespace SessionPrompt { for (const old of storedText) { if (old.id !== part.id) await Session.removePart({ sessionID, messageID: message.id, partID: old.id }) } - const cause = error ?? (message.finish !== "stop" ? { finish: message.finish ?? "unknown" } : undefined) + const cause = + error ?? message.metadata?.answer_commit_echo_error ?? + (message.finish !== "stop" ? { finish: message.finish ?? "unknown" } : undefined) if (cause) message.metadata = { ...message.metadata, answer_commit_echo_error: cause } delete message.error // A failed summariser becomes the exiting answer, so clients must no longer hide it as compaction machinery. diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 4fb27d667c..9b4b7dc49f 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -19,6 +19,11 @@ import { Session as LegacySession } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" import { MessageV2 } from "../../src/session/message-v2" import { SessionSummary } from "../../src/session/summary" +// altimate_change start — error provenance fixtures for committed-answer echoes +import { APICallError } from "ai" +import { SessionRetry } from "../../src/session/retry" +import { Snapshot } from "../../src/snapshot" +// altimate_change end import { RuntimeFlags } from "../../src/effect/runtime-flags" import { ModelID, ProviderID } from "../../src/provider/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -358,6 +363,11 @@ for (const scenario of [ "echo-mismatch", "echo-no-text", "echo-transport-error", + "echo-socket-reset", + "echo-policy-denied", + "echo-forbidden", + "echo-snapshot-error", + "echo-overflow-enabled", "compaction-error", "compaction-aborted", "compaction-content-filter", @@ -388,12 +398,17 @@ for (const scenario of [ "echo-length": "a length-limited echo is replaced with the stored committed output verbatim", "echo-mismatch": "a mismatched echo delivers the committed output exactly once to run output and ACP", "echo-no-text": "a stop echo without text publishes the committed output as a completed timed text part", - "echo-transport-error": "a non-retryable echo transport failure publishes the committed output and records metadata", + "echo-transport-error": "a 503 echo transport failure publishes the committed output without fatal events", + "echo-socket-reset": "a socket reset during echo publishes the committed output without fatal events", + "echo-policy-denied": "a 400 structured policy denial during echo emits fatal message and session errors", + "echo-forbidden": "a 403 echo failure retains its fatal error without publishing the committed output", + "echo-snapshot-error": "a local snapshot failure before the echo request retains its fatal UnknownError", + "echo-overflow-enabled": "a context overflow during echo publishes the committed output without fatal events", "compaction-error": "a failed compaction summariser after a commit publishes the committed output and records the cause", "compaction-aborted": "an aborted compaction after a commit retains its fatal error without publishing the committed output", "compaction-content-filter": "a content-filtered compaction after a commit retains its fatal error without publishing the committed output", "echo-overflow-disabled": "an unrecoverable context overflow after a commit publishes the committed output and records the cause", - "echo-preparation-error": "a thrown echo preparation error after a commit publishes the committed output and records the cause", + "echo-preparation-error": "a local echo preparation failure after a commit is not recovered", "echo-aborted": "an aborted echo retains its fatal error without publishing the committed output", "echo-content-filter": "a content-filtered echo retains its fatal error and original text", "echo-http-content-filter": "an HTTP content-filtered echo retains its fatal error without publishing the committed output", @@ -466,19 +481,29 @@ for (const scenario of [ const step = priorCommit && requests.length > 2 ? requests.length - 2 : requests.length if ( step === 2 && - ["echo-transport-error", "echo-http-content-filter", "compaction-error", "compaction-content-filter", "echo-overflow-disabled"].includes(scenario) + [ + "echo-transport-error", "echo-policy-denied", "echo-forbidden", "echo-http-content-filter", + "compaction-error", "compaction-content-filter", "echo-overflow-disabled", "echo-overflow-enabled", + ].includes(scenario) ) { const error = - scenario === "echo-http-content-filter" || scenario === "compaction-content-filter" - ? { code: "content_filter", message: "Echo request blocked by content policy" } - : scenario === "echo-overflow-disabled" - ? { code: "context_length_exceeded", message: "maximum context length exceeded" } - : { message: compacting ? "Compaction summariser rejected the request" : "Echo transport rejected the request" } + scenario === "echo-policy-denied" + ? { code: "policy_violation", message: "Request denied" } + : scenario === "echo-http-content-filter" || scenario === "compaction-content-filter" + ? { code: "content_filter", message: "Echo request blocked by content policy" } + : scenario === "echo-overflow-disabled" || scenario === "echo-overflow-enabled" + ? { code: "context_length_exceeded", message: "maximum context length exceeded" } + : { message: compacting ? "Compaction summariser rejected the request" : "Echo transport rejected the request" } return new Response(JSON.stringify({ error }), { - status: 400, + status: scenario === "echo-transport-error" || scenario === "compaction-error" + ? 503 + : scenario === "echo-forbidden" ? 403 : 400, headers: { "Content-Type": "application/json" }, }) } + if (step === 2 && scenario === "echo-socket-reset") { + throw Object.assign(new Error("socket reset"), { code: "ECONNRESET" }) + } if (step === 2 && (scenario === "echo-aborted" || scenario === "compaction-aborted")) { throw new DOMException("Echo request aborted", "AbortError") } @@ -557,10 +582,20 @@ for (const scenario of [ ), }).chatModel("test-model") const languageSpy = spyOn(Provider, "getLanguage").mockResolvedValue(language) + let echoPrepared = false + const track = Snapshot.track + const snapshotSpy = spyOn(Snapshot, "track").mockImplementation(async () => { + if (scenario === "echo-snapshot-error" && echoPrepared) throw new Error("Snapshot tracking failed") + return track() + }) + const retrySpy = scenario === "compaction-error" + ? spyOn(SessionRetry, "retryable").mockReturnValue(undefined) + : undefined const toModelMessages = MessageV2.toModelMessages const historySpy = spyOn(MessageV2, "toModelMessages").mockImplementation(async (...input) => { if (scenario === "echo-preparation-error" && requests.length === 1) throw new Error("Echo preparation failed") + if (requests.length === 1) echoPrepared = true const messages = await toModelMessages(...input) if (scenario === "queued-user" && requests.length === 1 && !queuedUserID) await insertUser() return messages @@ -570,6 +605,7 @@ for (const scenario of [ const textPartIDs = new Set() const textDeltas: { messageID: MessageID; text: string }[] = [] const sessionErrors: unknown[] = [] + const assistantUpdates: MessageV2.Assistant[] = [] const requestStarts: string[] = [] const collect = ({ payload: event }: GlobalEvent) => { const request = event as { type: string; properties: { sessionID?: string; messageID?: string } } @@ -582,6 +618,11 @@ for (const scenario of [ if (event.type === "session.error" && event.properties.sessionID === chat.id) { sessionErrors.push(event.properties.error) } + if (event.type === MessageV2.Event.Updated.type) { + const message = event.properties.info + if (message.sessionID === chat.id && message.role === "assistant") + assistantUpdates.push(structuredClone(message)) + } if (event.type === MessageV2.Event.PartUpdated.type) { const part = event.properties.part as MessageV2.Part if (part.sessionID !== chat.id) return @@ -631,7 +672,7 @@ for (const scenario of [ }), ) } - const result = yield* prompt.prompt({ + const pending = prompt.prompt({ sessionID: chat.id, agent: "analyst", model: promptModel, @@ -651,7 +692,18 @@ for (const scenario of [ } : {}), }) + if (scenario === "echo-preparation-error") { + const outcome = yield* Effect.exit(pending) + expect(outcome._tag).toBe("Failure") + expect(requests).toHaveLength(1) + expect(output.content()).toBe("(no text output — tool-only response)") + return + } + const result = yield* pending if ( + scenario === "echo-snapshot-error" || + scenario === "echo-policy-denied" || + scenario === "echo-forbidden" || scenario === "echo-aborted" || scenario === "echo-content-filter" || scenario === "echo-http-content-filter" || @@ -660,16 +712,20 @@ for (const scenario of [ ) { expect(result.info).toHaveProperty( "error.name", - scenario === "echo-aborted" || scenario === "compaction-aborted" - ? "MessageAbortedError" - : scenario === "echo-http-content-filter" || scenario === "compaction-content-filter" - ? "APIError" - : "ContentFilterError", + scenario === "echo-snapshot-error" + ? "UnknownError" + : scenario === "echo-aborted" || scenario === "compaction-aborted" + ? "MessageAbortedError" + : scenario === "echo-http-content-filter" || scenario === "compaction-content-filter" || + scenario === "echo-policy-denied" || scenario === "echo-forbidden" + ? "APIError" + : "ContentFilterError", ) expect(result.info).not.toHaveProperty("metadata.answer_commit_echo_error") expect(result.info).not.toHaveProperty("finish", "stop") - expect(requests).toHaveLength(2) + expect(requests).toHaveLength(scenario === "echo-snapshot-error" ? 1 : 2) expect(sessionErrors).toHaveLength(1) + expect(assistantUpdates.some((message) => message.id === result.info.id && message.error)).toBe(true) if (scenario !== "echo-content-filter") { expect(result.parts.filter((part) => part.type === "text")).toEqual([]) expect(output.content()).toBe("(no text output — tool-only response)") @@ -681,12 +737,12 @@ for (const scenario of [ } expect(result.info).not.toHaveProperty("error") if ( - scenario === "compaction-error" || scenario === "echo-overflow-disabled" || scenario === "echo-preparation-error" + scenario === "compaction-error" || scenario === "echo-overflow-disabled" || scenario === "echo-overflow-enabled" ) { const cause = scenario === "compaction-error" ? "Compaction summariser rejected the request" - : scenario === "echo-overflow-disabled" ? "maximum context length exceeded" : "Echo preparation failed" + : "maximum context length exceeded" expect(result.info).toHaveProperty("metadata.answer_commit_echo_error.data.message", cause) expect(result.info).toHaveProperty("finish", "stop") expect(result.info).not.toHaveProperty("summary", true) @@ -698,7 +754,11 @@ for (const scenario of [ renderedAnswer, ]) expect(output.content()).toBe(renderedAnswer) - expect(requests).toHaveLength(scenario === "echo-preparation-error" ? 1 : 2) + expect(requests).toHaveLength(2) + if (!compacting) { + expect(sessionErrors).toEqual([]) + expect(assistantUpdates.filter((message) => message.error)).toEqual([]) + } const history = yield* sessions.messages({ sessionID: chat.id }) if (compacting) { expect(JSON.stringify(requests[1].messages)).toContain("Provide a detailed prompt for continuing") @@ -716,18 +776,22 @@ for (const scenario of [ expect(persisted?.info).not.toHaveProperty("error") return } - if (scenario === "echo-transport-error") { + if (scenario === "echo-transport-error" || scenario === "echo-socket-reset") { + const cause = scenario === "echo-socket-reset" + ? "Cannot connect to API: socket reset" + : "Echo transport rejected the request" expect(sessionErrors).toEqual([]) + expect(assistantUpdates.filter((message) => message.error)).toEqual([]) expect(result.info).toHaveProperty( "metadata.answer_commit_echo_error.data.message", - "Echo transport rejected the request", + cause, ) const persisted = (yield* sessions.messages({ sessionID: chat.id })).find( (message) => message.info.id === result.info.id, ) expect(persisted?.info).toHaveProperty( "metadata.answer_commit_echo_error.data.message", - "Echo transport rejected the request", + cause, ) expect(persisted?.info).not.toHaveProperty("error") } @@ -834,6 +898,8 @@ for (const scenario of [ } } finally { GlobalBus.off("event", collect) + retrySpy?.mockRestore() + snapshotSpy.mockRestore() historySpy.mockRestore() languageSpy.mockRestore() } @@ -866,6 +932,86 @@ for (const scenario of [ 30_000, ) } + +describe("committed-answer echo error classification", () => { + for (const statusCode of [408, 429, 500, 503, 599]) { + test(`HTTP ${statusCode} is a recoverable echo transport failure`, () => { + const error = new MessageV2.APIError({ message: "Request failed", statusCode, isRetryable: false }).toObject() + expect(MessageV2.isEchoTransportError(error)).toBe(true) + }) + } + for (const statusCode of [400, 401, 403, 404]) { + test(`HTTP ${statusCode} is never a recoverable echo transport failure`, () => { + const error = new MessageV2.APIError({ message: "Request failed", statusCode, isRetryable: true }).toObject() + expect(MessageV2.isEchoTransportError(error)).toBe(false) + }) + } + for (const code of ["policy_violation", "content_filter", "safety_rejection", "moderation_blocked"]) { + test(`structured ${code} overrides an otherwise recoverable HTTP status`, () => { + const error = new MessageV2.APIError({ + message: "Request denied", + statusCode: 503, + isRetryable: true, + responseBody: JSON.stringify({ error: { code } }), + }).toObject() + expect(MessageV2.isEchoTransportError(error)).toBe(false) + }) + } + test("a policy code beyond the response-body limit survives API error normalization", () => { + const error = MessageV2.fromError(new APICallError({ + message: "Request denied", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 503, + isRetryable: true, + responseBody: JSON.stringify({ details: "x".repeat(5000), error: { code: "policy_violation" } }), + }), { providerID: promptModel.providerID }) + expect(error.name).toBe("APIError") + expect(MessageV2.isEchoTransportError(error)).toBe(false) + }) + test("a structured policy decision with overflow-looking text stays a fatal API error", () => { + const error = MessageV2.fromError(new APICallError({ + message: "maximum context length exceeded", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 400, + isRetryable: false, + responseBody: JSON.stringify({ error: { code: "policy_violation", message: "maximum context length exceeded" } }), + }), { providerID: promptModel.providerID }) + expect(error.name).toBe("APIError") + expect(MessageV2.isEchoTransportError(error)).toBe(false) + }) + test("a wrapped socket reset without a status remains recoverable even when the SDK disables retries", () => { + const error = MessageV2.fromError(new APICallError({ + message: "Connection interrupted", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + isRetryable: false, + cause: Object.assign(new Error("socket reset"), { code: "ECONNRESET" }), + }), { providerID: promptModel.providerID }) + expect(error.name).toBe("APIError") + expect(MessageV2.isEchoTransportError(error)).toBe(true) + }) + test("statusless socket and watchdog failures are recoverable but local failures and auth are not", () => { + for (const code of ["ECONNRESET", "ETIMEDOUT", "ProviderStreamIdleTimeoutError"]) { + const error = new MessageV2.APIError({ + message: "Transport interrupted", + isRetryable: false, + metadata: { code }, + }).toObject() + expect(MessageV2.isEchoTransportError(error)).toBe(true) + } + expect(MessageV2.isEchoTransportError( + new MessageV2.APIError({ message: "Unclassified failure", isRetryable: false }).toObject(), + )).toBe(false) + expect(MessageV2.isEchoTransportError( + MessageV2.fromError(new Error("Snapshot tracking failed"), { providerID: promptModel.providerID }), + )).toBe(false) + expect(MessageV2.isEchoTransportError( + new MessageV2.AuthError({ message: "Authentication failed", providerID: promptModel.providerID }).toObject(), + )).toBe(false) + }) +}) // altimate_change end test("analyst prompt gives StructuredOutput precedence only when an output JSON schema is supplied", async () => { diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index b3a691a72d..e305afe985 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -343,6 +343,29 @@ describe("answer_commit display precision", () => { }, ) + test("row precision requires unique column labels", async () => { + const payload = { + basis: recordBasis, + rows: { columns: ["Amount", "Amount"], values: [[5, 1.234]], precision: { Amount: 2 } }, + } + const error = /precision requires unique column labels/i + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(error) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + await rejects(tool.execute(payload, context), error) + }) + + test("duplicate row column labels render unchanged without precision", async () => { + const payload = { + basis: recordBasis, + answer_only: true, + rows: { columns: ["Amount", "Amount"], values: [[5, 1.234]] }, + } + const expected = "| Amount | Amount |\n|---|---|\n| 5 | 1.234 |" + expect(AnswerCommit.renderAnswer(payload)).toBe(expected) + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect((await tool.execute(payload, context)).output).toBe(expected) + }) + test("row precision accepts exact column names", async () => { const payload = { basis: recordBasis, diff --git a/packages/opencode/test/cli/run/budget-finalization.test.ts b/packages/opencode/test/cli/run/budget-finalization.test.ts index 2695a3bd21..d9594a3e1d 100644 --- a/packages/opencode/test/cli/run/budget-finalization.test.ts +++ b/packages/opencode/test/cli/run/budget-finalization.test.ts @@ -199,6 +199,230 @@ function isFinalization(body: Record) { } describe("opencode run: budget finalization subprocess", () => { + // altimate_change start — attached turn limits use persisted request events without session fetches + for (const sharedRequest of [false, true]) { + cliIt.concurrent( + sharedRequest + ? "a new persisted request on --attach --session clears the committed echo exemption" + : "an ordinary over-budget step requests abort without awaiting any session fetch", + ({ opencode }) => + Effect.gen(function* () { + const sessionID = "ses_budget_attached" + let stream: ReadableStreamDefaultController + let aborts = 0 + let sessionFetches = 0 + let prompts = 0 + let subscriptionReady = Promise.withResolvers() + let finishPrompt: (value: Response) => void = () => {} + const encoder = new TextEncoder() + const publish = (type: string, properties: object) => + stream.enqueue(encoder.encode(`data: ${JSON.stringify({ type, properties })}\n\n`)) + const assistant = (messageID: string, parentID: string) => ({ + id: messageID, + sessionID, + role: "assistant", + parentID, + agent: "build", + modelID: "test-model", + providerID: "test", + time: { created: Date.now() }, + }) + const start = (messageID: string, parentID: string) => { + publish("message.updated", { info: assistant(messageID, parentID) }) + publish("message.part.updated", { + part: { id: `${messageID}_start`, messageID, sessionID, type: "step-start" }, + }) + } + const server = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 0, + async fetch(request) { + const route = new URL(request.url).pathname + if (route === "/provider") + return Response.json({ all: [{ id: "test", models: { "test-model": {} } }], connected: ["test"] }) + if (route === "/config") return Response.json({ share: "disabled", tracing: { enabled: false } }) + if (route === "/event") + return new Response( + new ReadableStream({ + start(controller) { + stream = controller + subscriptionReady.resolve() + publish("server.connected", {}) + }, + }), + { + headers: { "content-type": "text/event-stream" }, + }, + ) + if (route === `/session/${sessionID}` && request.method === "GET") { + sessionFetches++ + // This never settles: a read on the step-start path would prevent the abort. + return new Promise(() => {}) + } + if (route === `/session/${sessionID}/abort`) { + aborts++ + const error = { name: "MessageAbortedError", data: { message: "aborted by turn limit" } } + publish("session.error", { sessionID, error }) + publish("session.status", { sessionID, status: { type: "idle" } }) + finishPrompt(Response.json({ info: { error } })) + return Response.json(true) + } + if (route === `/session/${sessionID}/message` && request.method === "POST") { + const body = (await request.json()) as { messageID: string } + // The SDK connects SSE lazily, including during finalization. + // Wait for this prompt's subscriber before publishing its events. + const ready = subscriptionReady + await ready.promise + subscriptionReady = Promise.withResolvers() + const parentID = body.messageID + const messageID = `${parentID}zz_assistant` + prompts++ + publish("session.status", { sessionID, status: { type: "busy" } }) + start(messageID, parentID) + if (prompts > 1) { + publish("message.part.updated", { + part: { + id: `${messageID}_text`, + messageID, + sessionID, + type: "text", + text: "Budget finalization completed.", + time: { start: 1, end: 2 }, + }, + }) + publish("message.part.updated", { + part: { + id: `${messageID}_finish`, + messageID, + sessionID, + type: "step-finish", + reason: "stop", + }, + }) + publish("session.status", { sessionID, status: { type: "idle" } }) + return Response.json({ info: { finish: "stop" } }) + } + if (sharedRequest) { + publish("message.part.updated", { + part: { + id: `${messageID}_commit`, + messageID, + sessionID, + type: "tool", + tool: "answer_commit", + state: { + status: "completed", + input: {}, + output: "Original committed answer.", + title: "Committed", + }, + }, + }) + // The original echo remains exempt, including synthetic compaction continuation rows. + const continuationID = `${parentID}_continuation` + publish("message.updated", { info: { id: continuationID, sessionID, role: "user" } }) + publish("message.part.updated", { + part: { + id: `${continuationID}_text`, + messageID: continuationID, + sessionID, + type: "text", + synthetic: true, + text: "Continue the compacted request.", + }, + }) + start(`${parentID}_echo`, continuationID) + const nextRequestID = "msg_000_shared_request" + publish("message.updated", { info: { id: nextRequestID, sessionID, role: "user" } }) + publish("message.part.updated", { + part: { + id: `${nextRequestID}_text`, + messageID: nextRequestID, + sessionID, + type: "text", + text: "A new user request.", + }, + }) + // The request boundary is emitted after the real user and its parts are persisted. + publish("session.request.started", { sessionID, messageID: nextRequestID }) + // A delayed update from the old request must not re-arm its exemption. + // Client-supplied user IDs need not sort after every earlier assistant ID. + publish("message.updated", { info: assistant(messageID, parentID) }) + publish("message.part.updated", { + part: { + id: `${messageID}_commit`, + messageID, + sessionID, + type: "tool", + tool: "answer_commit", + state: { + status: "completed", + input: {}, + output: "Stale committed answer.", + title: "Committed", + }, + }, + }) + // Its parent predates this subscription, so even the first observed + // update of this older assistant cannot belong to the new request. + const unseenAssistantID = `${parentID}zzz_unseen_assistant` + publish("message.updated", { info: assistant(unseenAssistantID, "msg_older_unobserved_parent") }) + publish("message.part.updated", { + part: { + id: `${unseenAssistantID}_commit`, + messageID: unseenAssistantID, + sessionID, + type: "tool", + tool: "answer_commit", + state: { + status: "completed", + input: {}, + output: "An earlier unobserved request committed.", + title: "Committed", + }, + }, + }) + start(`${nextRequestID}_assistant`, nextRequestID) + } else { + start(`${parentID}_second`, parentID) + } + return new Promise((resolve) => { + finishPrompt = resolve + }) + } + return Response.json({ error: `Unexpected route: ${request.method} ${route}` }, { status: 404 }) + }, + }), + ), + (server) => Effect.sync(() => server.stop(true)), + ) + const result = yield* opencode.run("Start the work.", { + format: "json", + extraArgs: ["--attach", server.url.toString(), "--session", sessionID, "--max-turns", "1", "--no-trace"], + timeoutMs: 20_000, + bunRun: true, + }) + opencode.expectExit(result, 1) + expect(aborts).toBe(1) + expect(sessionFetches).toBe(0) + expect(prompts).toBe(2) + const events = opencode.parseJsonEvents(result.stdout) + const budgetIndex = events.findIndex((event) => event.type === "budget_exhausted") + expect(budgetIndex).toBeGreaterThan(-1) + expect(events.slice(0, budgetIndex).filter((event) => event.type === "step_start")).toHaveLength( + sharedRequest ? 2 : 1, + ) + expect( + events.filter((event) => event.type === "text").map((event) => (event.part as { text: string }).text), + ).toEqual(["Budget finalization completed."]) + }), + 30_000, + ) + } + // altimate_change end // altimate_change start — a completed answer gets one tool-free echo beyond the work-turn limit cliIt.concurrent( "answer_commit on --max-turns 1 publishes the committed output on its extra echo turn", @@ -246,25 +470,22 @@ describe("opencode run: budget finalization subprocess", () => { 90_000, ) cliIt.concurrent( - "answer_commit on --max-turns 1 survives reactive compaction and publishes the retried echo on N+2", + "answer_commit on --max-turns 1 publishes the committed output after an echo context overflow", ({ llm, opencode }) => Effect.gen(function* () { - const committed = "Committed revenue after compaction: 42" + const committed = "Committed revenue after overflow: 42" yield* llm.tool("answer_commit", { fields: [{ name: "", value: committed }], answer_only: true, basis: "Summed orders.amount over all orders at order grain.", }) - // A streaming overflow begins the N+1 step before compaction retries it. + // A streaming overflow begins the echo step, whose committed text can finish the request. // The compatible provider exposes the error's message to the processor. yield* llm.push({ type: "sse", head: [], tail: [{ error: { message: JSON.stringify({ type: "error", error: { code: "context_length_exceeded" } }) } }], }) - yield* llm.text("The revenue answer was committed; return the stored answer.") - yield* llm.text("A different provider echo must be replaced after compaction.") - yield* llm.textMatch(({ body }) => isFinalization(body), "The retried echo was aborted by the turn limit.") const result = yield* opencode.run("Commit the revenue answer.", { agent: "analyst", @@ -278,19 +499,21 @@ describe("opencode run: budget finalization subprocess", () => { const events = opencode.parseJsonEvents(result.stdout) expect(events.filter((event) => event.type === "budget_exhausted")).toHaveLength(0) expect(events.filter((event) => event.type === "silent_turn_reply")).toHaveLength(0) - expect(events.filter((event) => event.type === "step_start")).toHaveLength(4) - const texts = events.filter((event) => event.type === "text").map((event) => (event.part as { text: string }).text) - expect(texts.at(-1)).toBe(committed) - expect(texts.filter((text) => text === committed)).toHaveLength(1) + expect(events.filter((event) => event.type === "error")).toHaveLength(0) + expect(events.filter((event) => event.type === "step_start")).toHaveLength(2) + const texts = events + .filter((event) => event.type === "text") + .map((event) => (event.part as { text: string }).text) + expect(texts).toEqual([committed]) expect(events.filter((event) => event.type === "termination")).toMatchObject([ - { why_model_stopped: "stop", why_harness_stopped: "none" }, + // The echo overflowed, so the model never produced a stop; the harness delivered the committed text. + { why_model_stopped: "tool-call", why_harness_stopped: "none" }, ]) const requests = (yield* llm.inputs).filter( (body) => !JSON.stringify(body).includes("Generate a title for this conversation"), ) - expect(requests).toHaveLength(4) - expect(JSON.stringify(requests[2])).toContain("Provide a detailed prompt for continuing") - expect(requests[3]!.tools ?? []).toEqual([]) + expect(requests).toHaveLength(2) + expect(requests[1]!.tools ?? []).toEqual([]) expect(requests.filter(isFinalization)).toHaveLength(0) }), 90_000, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 76c1644644..5e05207808 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -990,7 +990,7 @@ for (const mode of [ "compacted-unlimited", "compacted-new-request", "compacted-synthetic-request", - "compacted-retry", + "compacted-overflow", ] as const) { const compacts = mode.startsWith("compacted") const newRequest = mode === "compacted-new-request" || mode === "compacted-synthetic-request" @@ -1003,7 +1003,7 @@ for (const mode of [ "compacted-unlimited": "the exact rows after compaction without a step limit", "compacted-new-request": "the newer user request after compaction", "compacted-synthetic-request": "the newer synthetic user request after compaction", - "compacted-retry": "the exact rows after an echo overflow and compaction retry", + "compacted-overflow": "the exact rows after compaction and directly recovers an echo overflow", }[mode] noLLMServer.instance( `answer_commit preserves ${scenario}`, @@ -1011,7 +1011,7 @@ for (const mode of [ Effect.gen(function* () { const { prompt, chat, sessions } = yield* boot() const requests: Record[] = [] - const requestCount = mode === "compacted-retry" ? 5 : compacts ? 3 : 2 + const requestCount = compacts ? 3 : 2 const rows = Array.from({ length: 60 }, (_, index) => [`${index}: ${"x".repeat(500)}`]) const answer = compacts ? ["| value |", "|---|", ...rows.map(([value]) => `| ${value} |`)].join("\n") : "42" const args = @@ -1037,7 +1037,7 @@ for (const mode of [ const request = JSON.parse(init.body) requests.push(request) if (requests.length > requestCount) throw new Error("answer_commit exceeded its generation budget") - if (mode === "compacted-retry" && requests.length === 3) { + if (mode === "compacted-overflow" && requests.length === 3) { return new Response(JSON.stringify({ error: { message: "request entity too large" } }), { status: 413, headers: { "Content-Type": "application/json" }, @@ -1072,7 +1072,7 @@ for (const mode of [ ) .usage({ input: mode === "compacted-usage" ? 80_000 : compacts ? 70_000 : 1, output: 1 }) .item() - : compacts && (requests.length === 2 || (mode === "compacted-retry" && requests.length === 4)) + : compacts && requests.length === 2 ? reply().text("The rows were committed. Continue with the final reply.").stop().item() : mode === "structured" ? reply().tool("StructuredOutput", { count: 42 }).item() @@ -1115,6 +1115,14 @@ for (const mode of [ expect(result.info).not.toHaveProperty("error") expect(requests).toHaveLength(requestCount) const messages = yield* sessions.messages({ sessionID: chat.id }) + if (mode === "compacted-overflow") { + expect(result.info).toHaveProperty("finish", "stop") + expect(result.info).toHaveProperty("metadata.answer_commit_echo_error.name", "ContextOverflowError") + const persisted = messages.find((message) => message.info.id === result.info.id) + expect(persisted?.info).not.toHaveProperty("error") + expect(persisted?.info).toHaveProperty("metadata.answer_commit_echo_error.name", "ContextOverflowError") + expect(messages.filter((message) => message.info.role === "assistant" && message.info.summary)).toHaveLength(1) + } const commit = messages .flatMap((message) => message.parts) .find((part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "answer_commit") From 035b4ce1b2d2fc2e4e2dd0835cd8746bc41b71d5 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 20:55:42 -0700 Subject: [PATCH 22/39] fix(analyst): deliver the committed answer even when a new request arrives during the echo; revoke the turn exemption on a failed retry - A user request persisted while the tool-free echo was streaming made the finalizer give up before publishing, so the model's unvalidated echo was published for the earlier request and the loop stopped without processing the new one. The committed answer belongs to the earlier request: it is now published once (start, delta, completion, finish `stop`), the commit is cleared, and the loop continues to answer the queued request with tools. The pre-send check for a request that arrived before the echo was sent is unchanged. - The `--max-turns` echo exemption now lives in the run accounting and is revoked when a later `answer_commit` attempt for the same request errors, so a success followed by a failed retry no longer exempts an ordinary work turn. - Tests: a user message persisted during a mismatched echo preserves the committed answer and is answered in the same prompt call; a failed `answer_commit` retry clears the committed echo exemption; a successful commit grants it; the existing during-echo and after-snapshot cases strengthened. Co-Authored-By: Claude Fable 5.1 --- .../opencode/src/cli/cmd/run-accounting.ts | 14 ++++++ packages/opencode/src/cli/cmd/run.ts | 12 ++--- packages/opencode/src/session/prompt.ts | 13 +---- .../opencode/test/agent/answer-commit.test.ts | 34 +++++++++++-- .../test/cli/run/budget-finalization.test.ts | 50 +++++++++++++++++-- 5 files changed, 98 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run-accounting.ts b/packages/opencode/src/cli/cmd/run-accounting.ts index 01e6386811..33a501347a 100644 --- a/packages/opencode/src/cli/cmd/run-accounting.ts +++ b/packages/opencode/src/cli/cmd/run-accounting.ts @@ -57,6 +57,9 @@ export function createRecoverableOverflowTraceErrors() { export function create() { const summaryMessages = new Set() let turnCount = 0 + // altimate_change start — track the active request's committed echo turn allowance + let committedAnswer = false + // altimate_change end let lastFinishReason: string | undefined // altimate_change start — upstream_fix: onText and onStepFinish are // independently overwritten by whichever message last emitted a text/finish @@ -111,6 +114,17 @@ export function create() { } return { + // altimate_change start — only a successful commit exempts the request's echo from its turn budget + resetCommittedAnswer() { + committedAnswer = false + }, + onAnswerCommit(status: "completed" | "error") { + committedAnswer = status === "completed" + }, + get committedAnswer() { + return committedAnswer + }, + // altimate_change end /** Record whether an assistant message is actual compaction machinery. */ onAssistantMessage(info: { id: string; agent?: string; summary?: boolean }) { if (info.summary === true) summaryMessages.add(info.id) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f616368305..599d9391d6 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -725,7 +725,6 @@ You are speaking to a non-technical business executive. Follow these rules stric // altimate_change start — turn accounting + dual-attribution // termination state for this run (see run-accounting.ts). const accounting = RunAccounting.create() - let committedAnswer = false let activeRequestID: string | undefined const assistantRequests = new Map() const userRequests = new Map() @@ -737,7 +736,7 @@ You are speaking to a non-technical business executive. Follow these rules stric seenRequests.add(messageID) userRequests.set(messageID, messageID) activeRequestID = messageID - committedAnswer = false + accounting.resetCommittedAnswer() } // altimate_change end // altimate_change start — idle-done fallback state (run-mode-only by @@ -1036,11 +1035,12 @@ You are speaking to a non-technical business executive. Follow these rules stric // altimate_change start — tool steps cannot satisfy the final-answer check if (part.type === "tool" && !accounting.isCompactionStep(part.messageID)) lastToolStep = step if ( - part.type === "tool" && part.tool === "answer_commit" && part.state.status === "completed" && + part.type === "tool" && part.tool === "answer_commit" && + (part.state.status === "completed" || part.state.status === "error") && activeRequestID !== undefined && assistantRequests.get(part.messageID) === activeRequestID ) { - // Only this request's commit can exempt its echo from the work-turn limit. - committedAnswer = true + // Only this request's successful commit can exempt its echo; a failed retry revokes it. + accounting.onAnswerCommit(part.state.status) } // altimate_change end @@ -1097,7 +1097,7 @@ You are speaking to a non-technical business executive. Follow these rules stric const counted = accounting.onStepStart(part.messageID) // altimate_change start — the time-budget abort already owns the interrupted work phase if ( - counted && maxTurns && accounting.turnCount > maxTurns && !committedAnswer && !budgetAborted && + counted && maxTurns && accounting.turnCount > maxTurns && !accounting.committedAnswer && !budgetAborted && (!timeExhausted() || options?.turnLimit !== undefined) ) { stopTimeBudget() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 798e8e4ed9..4e9642001f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -879,17 +879,8 @@ export namespace SessionPrompt { ) ) return false - for (const msg of MessageV2.stream(sessionID)) { - if (msg.info.id <= committedAnswer.userID) break - if ( - msg.info.role === "user" && - !msg.parts.some((part) => part.type === "compaction") && - !compactionContinuations.has(msg.info.id) - ) { - committedAnswer = undefined - return false - } - } + // Once the echo has started, this answer still belongs to the earlier request. + // Publish it before the loop continues to any user request queued during generation. const storedText = MessageV2.parts(message.id).filter((part) => part.type === "text") const first = deferredText[0] ?? storedText[0] const part: MessageV2.TextPart = { diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 9b4b7dc49f..f4705664ea 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -385,6 +385,7 @@ for (const scenario of [ "new-user", "queued-user", "user-during-echo", + "user-during-mismatched-echo", "failed", "failed-after-success", "sibling-tool", @@ -393,6 +394,7 @@ for (const scenario of [ const priorCommit = scenario === "json_schema-history" const tinyCap = scenario === "echo-capped" || scenario === "echo-under-cap" const compacting = scenario.startsWith("compaction-") + const userDuringEcho = scenario === "user-during-echo" || scenario === "user-during-mismatched-echo" const name = { text: "text requests offer answer_commit and immediately echo a successful commit without a step limit", "echo-length": "a length-limited echo is replaced with the stored committed output verbatim", @@ -421,6 +423,7 @@ for (const scenario of [ "new-user": "a new user message resets a successful commit before the next step", "queued-user": "a user message persisted after the snapshot keeps tools enabled and prevents stale echo", "user-during-echo": "a user message persisted during echo generation is answered on the next step", + "user-during-mismatched-echo": "a user message persisted during a mismatched echo preserves the committed answer and is answered in the same prompt call", failed: "a failed commit leaves tools available for repair", "failed-after-success": "a successful commit followed by validation failure leaves tools available for repair", "sibling-tool": "a successful commit enters echo mode even when a sibling tool follows it", @@ -476,7 +479,7 @@ for (const scenario of [ async (_url: string | URL | Request, init?: RequestInit) => { if (typeof init?.body !== "string") throw new Error("expected a JSON request body") requests.push(JSON.parse(init.body)) - if (requests.length > (priorCommit ? 4 : scenario === "user-during-echo" ? 3 : 2)) + if (requests.length > (priorCommit ? 4 : userDuringEcho ? 3 : 2)) throw new Error("unexpected extra generation") const step = priorCommit && requests.length > 2 ? requests.length - 2 : requests.length if ( @@ -509,7 +512,7 @@ for (const scenario of [ } if ( (scenario === "new-user" && requests.length === 1) || - (scenario === "user-during-echo" && requests.length === 2) + (userDuringEcho && requests.length === 2) ) { await insertUser() } @@ -534,7 +537,7 @@ for (const scenario of [ ? "3" : scenario === "echo-length" ? "4" - : scenario === "echo-mismatch" || scenario === "echo-other" + : scenario === "echo-mismatch" || scenario === "echo-other" || scenario === "user-during-mismatched-echo" ? "The answer is 42." : "42", ) @@ -604,6 +607,7 @@ for (const scenario of [ const textUpdates: MessageV2.TextPart[] = [] const textPartIDs = new Set() const textDeltas: { messageID: MessageID; text: string }[] = [] + const textEvents: { messageID: MessageID; type: string; text: string }[] = [] const sessionErrors: unknown[] = [] const assistantUpdates: MessageV2.Assistant[] = [] const requestStarts: string[] = [] @@ -630,6 +634,7 @@ for (const scenario of [ if (part.type !== "text") return // Snapshot the published value: later mutations must not rewrite what a client observed. textUpdates.push(structuredClone(part)) + textEvents.push({ messageID: part.messageID, type: part.time?.end ? "completion" : "start", text: part.text }) textPartIDs.add(part.id) // Match run.ts: only completed text parts are printed and collected for --output. if (part.time?.end) output.text(part.text.trim(), { synthetic: part.synthetic === true }) @@ -642,6 +647,7 @@ for (const scenario of [ ) { // ACP forwards deltas for known text parts and ignores later text updates. textDeltas.push({ messageID: event.properties.messageID, text: event.properties.delta }) + textEvents.push({ messageID: event.properties.messageID, type: "delta", text: event.properties.delta }) } } GlobalBus.on("event", collect) @@ -795,7 +801,7 @@ for (const scenario of [ ) expect(persisted?.info).not.toHaveProperty("error") } - expect(requests).toHaveLength(priorCommit ? 4 : scenario === "user-during-echo" ? 3 : 2) + expect(requests).toHaveLength(priorCommit ? 4 : userDuringEcho ? 3 : 2) const tools = (index: number) => (requests[index].tools ?? []).map((tool) => tool.function.name) if (structured) { for (const request of priorCommit ? requests.slice(2) : requests) { @@ -877,13 +883,31 @@ for (const scenario of [ // A correct echo publishes only its initial and completed text parts. expect(finalUpdates).toHaveLength(2) } - } else if (scenario === "user-during-echo") { + } else if (userDuringEcho) { + if (commit?.type !== "tool" || commit.state.status !== "completed") throw new Error("missing commit") + const commitMessage = messages.find((message) => message.info.id === commit.messageID) + if (commitMessage?.info.role !== "assistant") throw new Error("missing commit message") + const committedUserID = commitMessage.info.parentID + const echo = messages.find( + (message) => message.info.role === "assistant" && + message.info.parentID === committedUserID && message.info.finish === "stop", + ) + if (!echo) throw new Error("missing completed echo for the original request") + expect(echo.info).not.toHaveProperty("error") + expect(echo.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual([renderedAnswer]) + expect(textEvents.filter((event) => event.messageID === echo.info.id)).toEqual([ + { messageID: echo.info.id, type: "start", text: "" }, + { messageID: echo.info.id, type: "delta", text: renderedAnswer }, + { messageID: echo.info.id, type: "completion", text: renderedAnswer }, + ]) expect(tools(1)).toEqual([]) expect(JSON.stringify(requests[1].messages)).toContain(echoInstruction) expect(tools(2)).toContain("answer_commit") expect(JSON.stringify(requests[2].messages)).not.toContain(echoInstruction) expect(JSON.stringify(requests[2].messages)).toContain("A new question: how many sources?") + expect(queuedUserID).toBeDefined() expect(result.info).toHaveProperty("parentID", queuedUserID) + expect(result.info).toHaveProperty("finish", "stop") expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: "3" })) } else { expect(tools(1)).toContain("answer_commit") diff --git a/packages/opencode/test/cli/run/budget-finalization.test.ts b/packages/opencode/test/cli/run/budget-finalization.test.ts index d9594a3e1d..7db709e630 100644 --- a/packages/opencode/test/cli/run/budget-finalization.test.ts +++ b/packages/opencode/test/cli/run/budget-finalization.test.ts @@ -18,6 +18,24 @@ function exhausted() { } describe("budget finalization accounting", () => { + // altimate_change start — a failed answer_commit retry revokes the prior echo turn allowance + test("a successful answer_commit grants the committed echo exemption", () => { + const accounting = RunAccounting.create() + expect(accounting.committedAnswer).toBe(false) + accounting.onAnswerCommit("completed") + expect(accounting.committedAnswer).toBe(true) + accounting.resetCommittedAnswer() + expect(accounting.committedAnswer).toBe(false) + }) + + test("a failed answer_commit retry clears the committed echo exemption", () => { + const accounting = RunAccounting.create() + accounting.onAnswerCommit("completed") + expect(accounting.committedAnswer).toBe(true) + accounting.onAnswerCommit("error") + expect(accounting.committedAnswer).toBe(false) + }) + // altimate_change end // altimate_change start — distinguish recovered wall-clock limits from turn limits and real failures test("a successful time-budget finalization clears fatal status but keeps budget attribution", () => { const accounting = RunAccounting.create() @@ -200,11 +218,15 @@ function isFinalization(body: Record) { describe("opencode run: budget finalization subprocess", () => { // altimate_change start — attached turn limits use persisted request events without session fetches - for (const sharedRequest of [false, true]) { + for (const scenario of ["ordinary", "shared-request", "failed-commit-retry"] as const) { + const sharedRequest = scenario === "shared-request" + const failedRetry = scenario === "failed-commit-retry" cliIt.concurrent( sharedRequest ? "a new persisted request on --attach --session clears the committed echo exemption" - : "an ordinary over-budget step requests abort without awaiting any session fetch", + : failedRetry + ? "a successful answer_commit followed by a failed retry revokes the committed echo exemption" + : "an ordinary over-budget step requests abort without awaiting any session fetch", ({ opencode }) => Effect.gen(function* () { const sessionID = "ses_budget_attached" @@ -305,7 +327,7 @@ describe("opencode run: budget finalization subprocess", () => { publish("session.status", { sessionID, status: { type: "idle" } }) return Response.json({ info: { finish: "stop" } }) } - if (sharedRequest) { + if (sharedRequest || failedRetry) { publish("message.part.updated", { part: { id: `${messageID}_commit`, @@ -321,6 +343,20 @@ describe("opencode run: budget finalization subprocess", () => { }, }, }) + } + if (failedRetry) { + publish("message.part.updated", { + part: { + id: `${messageID}_commit_retry`, + messageID, + sessionID, + type: "tool", + tool: "answer_commit", + state: { status: "error", input: {}, error: "The retry failed validation." }, + }, + }) + } + if (sharedRequest) { // The original echo remains exempt, including synthetic compaction continuation rows. const continuationID = `${parentID}_continuation` publish("message.updated", { info: { id: continuationID, sessionID, role: "user" } }) @@ -410,6 +446,12 @@ describe("opencode run: budget finalization subprocess", () => { expect(sessionFetches).toBe(0) expect(prompts).toBe(2) const events = opencode.parseJsonEvents(result.stdout) + if (failedRetry) { + expect(events.filter((event) => event.type === "tool_use").map((event) => event.part)).toEqual([ + expect.objectContaining({ tool: "answer_commit", state: expect.objectContaining({ status: "completed" }) }), + expect.objectContaining({ tool: "answer_commit", state: expect.objectContaining({ status: "error" }) }), + ]) + } const budgetIndex = events.findIndex((event) => event.type === "budget_exhausted") expect(budgetIndex).toBeGreaterThan(-1) expect(events.slice(0, budgetIndex).filter((event) => event.type === "step_start")).toHaveLength( @@ -545,7 +587,9 @@ describe("opencode run: budget finalization subprocess", () => { expect(events.filter((event) => event.type === "budget_exhausted")).toMatchObject([{ limit: 1 }]) const budgetIndex = events.findIndex((event) => event.type === "budget_exhausted") expect(events.slice(0, budgetIndex).filter((event) => event.type === "step_start")).toHaveLength(2) + // The commit belongs to the first request and is delivered before the reminder request is answered. expect(events.filter((event) => event.type === "text").map((event) => (event.part as { text: string }).text)).toEqual([ + "The original committed answer.", "The new request exhausted its turn budget.", ]) expect((yield* llm.inputs).filter(isFinalization)).toHaveLength(1) From f4b011108a4d6c61aa93c990c8758f62091fcaf7 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 21:21:40 -0700 Subject: [PATCH 23/39] fix(analyst): order requests by persisted time and sequence, not message ids; finalize an echo that triggers compaction - `PromptInput.messageID` accepts caller-supplied strings, so every "newer request" decision that compared ids could miss a later request whose id sorts earlier and publish the old committed answer for the new question. Requests are now ordered by the message table's persisted creation time with the insertion sequence as tie-breaker; the finalization guard, the pre-send re-check, the queued-request reminder and the loop exit check (now by assistant parent) all use it. - A committed-answer echo whose step result is `compact` is finalized from the stored output (single publish, finish `stop`, cause recorded) instead of publishing the unverified buffered echo and retrying after compaction. - Tests: later caller-supplied id sorting before the committed request is detected at the next snapshot, after the snapshot, and still preserves the earlier echo with tools available; equal creation times fall back to the persisted sequence; an echo triggering reactive compaction publishes only the committed text without a continuation. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/prompt.ts | 76 +++++++++++++--- .../opencode/test/agent/answer-commit.test.ts | 91 +++++++++++++++++-- 2 files changed, 147 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4e9642001f..1812f9fec2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -26,6 +26,8 @@ import { Instance } from "../project/instance" import { Bus } from "../bus" // altimate_change start - distinguish submitted requests from compaction-created user rows in the stream import { BusEvent } from "../bus/bus-event" +import { Database, eq, sql } from "../storage/db" +import { MessageTable } from "@opencode-ai/core/session/sql" // altimate_change end import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" @@ -828,6 +830,35 @@ export namespace SessionPrompt { // altimate_change start - retain the exact committed text through automatic compaction let committedAnswer: { userID: MessageID; output: string } | undefined const compactionContinuations = new Set() + // The legacy message table has no explicit sequence column. Its SQLite rowid + // records insertion order and survives updateMessage's data-only upserts. + // Read it with the stored timestamp: caller-provided IDs carry no chronology. + const readMessageOrder = () => new Map( + Database.use((db) => db + .select({ id: MessageTable.id, created: MessageTable.time_created, sequence: sql`rowid` }) + .from(MessageTable) + .where(eq(MessageTable.session_id, sessionID)) + .all()).map((row) => [row.id, row]), + ) + const compareMessageOrder = (order: ReturnType, leftID: MessageID, rightID: MessageID) => { + const left = order.get(leftID) + const right = order.get(rightID) + if (!left || !right) return 0 + return left.created - right.created || left.sequence - right.sequence + } + function* orderedMessageStream(order: ReturnType) { + // stream() already orders by stored timestamp; repair only tied groups, + // retaining lazy history hydration and filterCompacted's summary placement. + let tied: MessageV2.WithParts[] = [] + for (const msg of MessageV2.stream(sessionID)) { + if (tied.length && order.get(tied[0].info.id)?.created !== order.get(msg.info.id)?.created) { + yield* tied.sort((a, b) => compareMessageOrder(order, b.info.id, a.info.id)) + tied = [] + } + tied.push(msg) + } + yield* tied.sort((a, b) => compareMessageOrder(order, b.info.id, a.info.id)) + } const captureCommittedAnswer = (user: MessageV2.User, assistant: MessageV2.Assistant, parts: MessageV2.Part[]) => { if (assistant.parentID !== user.id || assistant.summary || !parts.some((part) => part.type === "tool")) return const commit = parts.findLast((part) => part.type === "tool" && part.tool === "answer_commit") @@ -865,7 +896,10 @@ export namespace SessionPrompt { deferredText: MessageV2.TextPart[] = [], error = message.error, ) => { - if (!committedAnswer || abort.aborted || message.id <= committedAnswer.userID) return false + if ( + !committedAnswer || abort.aborted || + compareMessageOrder(readMessageOrder(), message.id, committedAnswer.userID) <= 0 + ) return false // Keep aborts and HTTP policy rejections fatal even if a later operation throws another error. if ( message.finish === "content-filter" || @@ -925,7 +959,8 @@ export namespace SessionPrompt { // compaction marker, hydrate the stream once and pass that unfiltered // history to the ledger. Previously filterCompacted read the recent view // and process() synchronously hydrated the entire session a second time. - const messageStream = MessageV2.stream(sessionID) + const messageOrder = readMessageOrder() + const messageStream = orderedMessageStream(messageOrder) const firstMessage = messageStream.next() const newestIsCompaction = !firstMessage.done && firstMessage.value.parts.some((part) => part.type === "compaction") @@ -971,12 +1006,16 @@ export namespace SessionPrompt { msgs.some( (msg) => msg.info.role === "user" && - msg.info.id > committedAnswer!.userID && + compareMessageOrder(messageOrder, msg.info.id, committedAnswer!.userID) > 0 && !msg.parts.some((part) => part.type === "compaction") && !compactionContinuations.has(msg.info.id), ) - ) + ) { + // A request queued after the commit still receives its own response; + // publish the completed answer for the earlier request first. + if (lastAssistant) await finishCommittedAnswer(lastAssistant) committedAnswer = undefined + } if (commitRequest?.info.role === "user" && lastAssistant) { // Text-only echoes and compaction summaries retain the commit until delivery or a new request. captureCommittedAnswer( @@ -1020,7 +1059,7 @@ export namespace SessionPrompt { lastAssistant?.finish && !["tool-calls", "unknown"].includes(lastAssistant.finish) && !lastAssistantHasToolParts && - lastUser.id < lastAssistant.id + lastAssistant.parentID === lastUser.id ) { log.info("exiting loop", { sessionID }) break @@ -1720,7 +1759,9 @@ export namespace SessionPrompt { // Ephemerally wrap queued user messages with a reminder to stay on track if (step > 1 && lastFinished) { for (const msg of msgs) { - if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue + // altimate_change start - queued request order is independent of caller-supplied IDs + if (msg.info.role !== "user" || compareMessageOrder(messageOrder, msg.info.id, lastFinished.id) <= 0) continue + // altimate_change end for (const part of msg.parts) { if (part.type !== "text" || part.ignored || part.synthetic) continue if (!part.text.trim()) continue @@ -1872,11 +1913,12 @@ export namespace SessionPrompt { // altimate_change start - recheck persisted requests after asynchronous history conversion, before sending an echo const modelMessages = await MessageV2.toModelMessages(modelHistory, model) if (echoCommittedAnswer && committedAnswer) { + const order = readMessageOrder() let newerUserRequest = false for (const msg of MessageV2.stream(sessionID)) { - if (msg.info.id <= committedAnswer.userID) break if ( msg.info.role === "user" && + compareMessageOrder(order, msg.info.id, committedAnswer.userID) > 0 && !msg.parts.some((part) => part.type === "compaction") && !compactionContinuations.has(msg.info.id) ) { @@ -1885,8 +1927,7 @@ export namespace SessionPrompt { } } if (newerUserRequest) { - committedAnswer = undefined - await Session.removeMessage({ sessionID, messageID: processor.message.id }) + await finishCommittedAnswer(processor.message) continue } } @@ -1939,11 +1980,20 @@ export namespace SessionPrompt { const stepParts = MessageV2.parts(processor.message.id) // Capture before post-processing can fail or request compaction. captureCommittedAnswer(lastUser, processor.message, stepParts) + if (echoCommittedAnswer && result === "compact") { + processor.message.metadata = { + ...processor.message.metadata, + answer_commit_echo_error: processor.message.metadata?.answer_commit_echo_error ?? { + result, + finish: processor.message.finish ?? "unknown", + }, + } + } const echoFinished = - echoCommittedAnswer && result !== "compact" && (await finishCommittedAnswer(processor.message, deferredText)) + echoCommittedAnswer && (await finishCommittedAnswer(processor.message, deferredText)) // Abort and content-filter exits retain their original buffered text and timing. if (!echoFinished) await publishText(deferredText) - // An input overflow has no finish, so it still follows the existing compaction retry below. + // A finalized echo already has its answer, including when usage requested compaction. // altimate_change end // If structured output was captured, save it and exit immediately // This takes priority because the StructuredOutput tool was called successfully @@ -2293,7 +2343,7 @@ export namespace SessionPrompt { } let recovered = false if (committedAnswer) { - const exiting = [...MessageV2.stream(sessionID)].find((msg) => msg.info.role === "assistant") + const exiting = [...orderedMessageStream(readMessageOrder())].find((msg) => msg.info.role === "assistant") if (exiting?.info.role === "assistant") { recovered = await finishCommittedAnswer( exiting.info, @@ -2419,7 +2469,7 @@ export namespace SessionPrompt { await Telemetry.shutdown() // altimate_change end // altimate_change start — resolve callbacks from this prompt generation only - for await (const item of MessageV2.stream(sessionID)) { + for (const item of orderedMessageStream(readMessageOrder())) { if (item.info.role === "user") continue // Resolve only callers queued on THIS loop generation. A cancelled loop // may finish unwinding after a replacement generation has already begun; diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index f4705664ea..0e4623a7ef 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -368,6 +368,7 @@ for (const scenario of [ "echo-forbidden", "echo-snapshot-error", "echo-overflow-enabled", + "echo-reactive-compaction", "compaction-error", "compaction-aborted", "compaction-content-filter", @@ -383,9 +384,13 @@ for (const scenario of [ "json_schema", "json_schema-history", "new-user", + "new-user-after-commit-reversed-ids", "queued-user", + "queued-user-reversed-ids", "user-during-echo", "user-during-mismatched-echo", + "user-during-echo-reversed-ids", + "user-during-echo-reversed-ids-same-time", "failed", "failed-after-success", "sibling-tool", @@ -394,7 +399,10 @@ for (const scenario of [ const priorCommit = scenario === "json_schema-history" const tinyCap = scenario === "echo-capped" || scenario === "echo-under-cap" const compacting = scenario.startsWith("compaction-") - const userDuringEcho = scenario === "user-during-echo" || scenario === "user-during-mismatched-echo" + const userDuringEcho = scenario.startsWith("user-during-") + const queuedUser = scenario === "queued-user" || scenario === "queued-user-reversed-ids" + const userAfterCommit = scenario === "new-user-after-commit-reversed-ids" + const reversedIDs = scenario.includes("reversed-ids") const name = { text: "text requests offer answer_commit and immediately echo a successful commit without a step limit", "echo-length": "a length-limited echo is replaced with the stored committed output verbatim", @@ -406,6 +414,7 @@ for (const scenario of [ "echo-forbidden": "a 403 echo failure retains its fatal error without publishing the committed output", "echo-snapshot-error": "a local snapshot failure before the echo request retains its fatal UnknownError", "echo-overflow-enabled": "a context overflow during echo publishes the committed output without fatal events", + "echo-reactive-compaction": "an echo triggering reactive compaction publishes only the committed text without a continuation", "compaction-error": "a failed compaction summariser after a commit publishes the committed output and records the cause", "compaction-aborted": "an aborted compaction after a commit retains its fatal error without publishing the committed output", "compaction-content-filter": "a content-filtered compaction after a commit retains its fatal error without publishing the committed output", @@ -421,9 +430,13 @@ for (const scenario of [ json_schema: "json_schema requests omit answer_commit and never enter echo mode", "json_schema-history": "json_schema requests preserve masked commit history without re-advertising answer_commit", "new-user": "a new user message resets a successful commit before the next step", + "new-user-after-commit-reversed-ids": "a later caller-supplied ID sorting before the committed request is detected at the next loop snapshot", "queued-user": "a user message persisted after the snapshot keeps tools enabled and prevents stale echo", + "queued-user-reversed-ids": "a later caller-supplied ID sorting before the committed request is detected after the snapshot", "user-during-echo": "a user message persisted during echo generation is answered on the next step", "user-during-mismatched-echo": "a user message persisted during a mismatched echo preserves the committed answer and is answered in the same prompt call", + "user-during-echo-reversed-ids": "a later caller-supplied ID sorting before the committed request preserves its echo and keeps tools available", + "user-during-echo-reversed-ids-same-time": "equal request creation times use persisted sequence for caller-supplied IDs sorting backwards", failed: "a failed commit leaves tools available for repair", "failed-after-success": "a successful commit followed by validation failure leaves tools available for repair", "sibling-tool": "a successful commit enters echo mode even when a sibling tool follows it", @@ -452,8 +465,31 @@ for (const scenario of [ basis: "Counted all records at record grain using records.id.", } const renderedAnswer = renderAnswer(args) + const originalUserID = reversedIDs ? MessageID.make(`msg_zzzz_original_${chat.id}`) : undefined let queuedUserID: MessageID | undefined const insertUser = async () => { + if (reversedIDs) { + const original = MessageV2.get({ sessionID: chat.id, messageID: originalUserID! }) + const createdSpy = scenario === "user-during-echo-reversed-ids-same-time" + ? spyOn(Date, "now").mockReturnValue(original.info.time.created) + : undefined + try { + const next = await SessionPrompt.prompt({ + sessionID: chat.id, + messageID: MessageID.make(`msg_aaaa_later_${chat.id}`), + agent: "analyst", + model: promptModel, + noReply: true, + parts: [{ type: "text", text: "A new question: how many sources?" }], + }) + expect(next.info.id < original.info.id).toBe(true) + if (createdSpy) expect(next.info.time.created).toBe(original.info.time.created) + queuedUserID = next.info.id + } finally { + createdSpy?.mockRestore() + } + return + } const userID = MessageID.ascending() await LegacySession.updateMessage({ id: userID, @@ -533,14 +569,16 @@ for (const scenario of [ ? reply().tool("StructuredOutput", { count: 42 }).item() : reply() .text( - scenario === "new-user" || scenario === "queued-user" || step === 3 + scenario === "new-user" || queuedUser || userAfterCommit || step === 3 ? "3" : scenario === "echo-length" ? "4" - : scenario === "echo-mismatch" || scenario === "echo-other" || scenario === "user-during-mismatched-echo" + : scenario === "echo-mismatch" || scenario === "echo-other" || scenario === "echo-reactive-compaction" || + scenario === "user-during-mismatched-echo" || reversedIDs ? "The answer is 42." : "42", ) + .usage({ input: scenario === "echo-reactive-compaction" ? 95000 : 0, output: 1 }) .stop() .item() if (response.type !== "sse") throw new Error("expected SSE response") @@ -585,6 +623,15 @@ for (const scenario of [ ), }).chatModel("test-model") const languageSpy = spyOn(Provider, "getLanguage").mockResolvedValue(language) + const updatePart = LegacySession.updatePart + const commitSpy = userAfterCommit + ? spyOn(LegacySession, "updatePart").mockImplementation(Object.assign(async (part: MessageV2.Part) => { + const result = await updatePart(part) + if (part.type === "tool" && part.tool === "answer_commit" && part.state.status === "completed" && !queuedUserID) + await insertUser() + return result + }, updatePart)) + : undefined let echoPrepared = false const track = Snapshot.track const snapshotSpy = spyOn(Snapshot, "track").mockImplementation(async () => { @@ -600,7 +647,7 @@ for (const scenario of [ throw new Error("Echo preparation failed") if (requests.length === 1) echoPrepared = true const messages = await toModelMessages(...input) - if (scenario === "queued-user" && requests.length === 1 && !queuedUserID) await insertUser() + if (queuedUser && requests.length === 1 && !queuedUserID) await insertUser() return messages }) const output = new OutputCollector() @@ -680,6 +727,7 @@ for (const scenario of [ } const pending = prompt.prompt({ sessionID: chat.id, + ...(originalUserID ? { messageID: originalUserID } : {}), agent: "analyst", model: promptModel, parts: [{ type: "text", text: "How many records? Reply with only the count." }], @@ -742,6 +790,24 @@ for (const scenario of [ return } expect(result.info).not.toHaveProperty("error") + if (scenario === "echo-reactive-compaction") { + expect(result.info).toHaveProperty("finish", "stop") + expect(result.info).toHaveProperty("metadata.answer_commit_echo_error.result", "compact") + expect(result.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual([renderedAnswer]) + expect(textUpdates.filter((part) => part.time?.end).map((part) => part.text)).toEqual([renderedAnswer]) + expect(textDeltas.map((part) => part.text)).toEqual([renderedAnswer]) + expect(output.content()).toBe(renderedAnswer) + expect(requests).toHaveLength(2) + expect(JSON.stringify(requests[1].messages)).toContain(echoInstruction) + const history = yield* sessions.messages({ sessionID: chat.id }) + expect(history.flatMap((message) => message.parts).filter((part) => part.type === "compaction")).toEqual([]) + const users = history.filter((message) => message.info.role === "user") + expect(users).toHaveLength(1) + expect(requestStarts).toEqual([users[0].info.id]) + expect(history.find((message) => message.info.id === result.info.id)?.info) + .toHaveProperty("metadata.answer_commit_echo_error.result", "compact") + return + } if ( scenario === "compaction-error" || scenario === "echo-overflow-disabled" || scenario === "echo-overflow-enabled" ) { @@ -908,20 +974,31 @@ for (const scenario of [ expect(queuedUserID).toBeDefined() expect(result.info).toHaveProperty("parentID", queuedUserID) expect(result.info).toHaveProperty("finish", "stop") - expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: "3" })) + expect(result.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual(["3"]) } else { expect(tools(1)).toContain("answer_commit") expect(JSON.stringify(requests[1].messages)).not.toContain(echoInstruction) - if (scenario === "queued-user") { + if (queuedUser || userAfterCommit) { expect(queuedUserID).toBeDefined() expect(JSON.stringify(requests[1].messages)).toContain("A new question: how many sources?") expect(result.info).toHaveProperty("parentID", queuedUserID) - expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: "3" })) + expect(result.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual(["3"]) + if (reversedIDs) { + const originalAnswer = messages.find( + (message) => message.info.role === "assistant" && + message.info.parentID === originalUserID && message.info.finish === "stop", + ) + expect(originalAnswer?.parts.filter((part) => part.type === "text").map((part) => part.text)) + .toEqual([renderedAnswer]) + expect(textUpdates.filter((part) => part.messageID === originalAnswer?.info.id && part.time?.end) + .map((part) => part.text)).toEqual([renderedAnswer]) + } } } } } finally { GlobalBus.off("event", collect) + commitSpy?.mockRestore() retrySpy?.mockRestore() snapshotSpy.mockRestore() historySpy.mockRestore() From efb87dcf5c5f1598c2ab99a68bffae82b30f7e7c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 21:45:57 -0700 Subject: [PATCH 24/39] fix(analyst): persist the committed text before finishing; classify policy codes explicitly; keep recoverable compaction failures non-fatal - The committed text part is published and persisted before the assistant message is marked finished, and the processor defers its own finished-message writes for buffered echoes, so an interruption between the two steps can no longer leave a finished echo without its answer. - Policy classification uses explicit codes (`policy_violation`, `content_filter`, `content_policy`, `content_policy_violation`, `guardrail_intervened`, `safety_*`, `moderation_*`), including nested orchestration guardrail codes carried in a 503, and never treats `content_length_exceeded` as policy: a provider 413 stays a context overflow and takes the compaction path. An existing overflow classification is never overridden by a policy code. - An error-free `other` echo publishes the committed text but keeps `other` as its finish so headless accounting still reports the abnormal stream. - When a committed answer exists and the compaction summariser fails with a recoverable transport error, the compaction path records the cause in metadata instead of a fatal error and skips `session.error`; the recovery guard publishes the answer and the run exits clean. Auth, policy and abort failures stay fatal. - Tests: text persisted before any finished-message update; 503 guardrail denial fatal during echo and compaction; structured policy codes override a recoverable status; 413 `content_length_exceeded` stays an overflow and runs compaction; `other` finish kept with committed text; 503 summariser failure after a commit publishes the committed output without fatal events; 403 summariser failure stays fatal. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/compaction.ts | 11 ++ packages/opencode/src/session/message-v2.ts | 27 +++-- packages/opencode/src/session/processor.ts | 20 +++- packages/opencode/src/session/prompt.ts | 10 +- .../opencode/test/agent/answer-commit.test.ts | 110 +++++++++++++++--- packages/opencode/test/session/prompt.test.ts | 66 +++++++++++ 6 files changed, 211 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 1e2e3bde52..aedb3e30f8 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1290,6 +1290,7 @@ export namespace SessionCompaction { overflow?: boolean // altimate_change start - let the prompt loop retain request-scoped answers across compaction onContinuation?: (messageID: MessageID) => void + recoverCommittedAnswer?: boolean // altimate_change end // altimate_change start — keep nudge delivery scoped to the active prompt generation nudgeGeneration?: NudgeArbiter.Generation @@ -1444,6 +1445,9 @@ export namespace SessionCompaction { // altimate_change start — keep nudge delivery scoped to this prompt generation nudgeGeneration: input.nudgeGeneration, // altimate_change end + // altimate_change start - the prompt owns the current request's committed answer + recoverCommittedAnswer: input.recoverCommittedAnswer, + // altimate_change end }) // Allow plugins to inject context or replace compaction prompt const compacting = await Plugin.trigger( @@ -1612,6 +1616,13 @@ When constructing the summary, try to stick to this template: } // altimate_change end + // altimate_change start - exit without a fatal summary or continuation before the prompt publishes the answer + if (input.recoverCommittedAnswer && processor.message.metadata?.answer_commit_echo_error) { + compactionAttempts.delete(input.sessionID) + return "stop" + } + // altimate_change end + if (result === "compact") { processor.message.error = new MessageV2.ContextOverflowError({ message: replay diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 6557e8b893..9763af94a0 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -534,12 +534,25 @@ export namespace MessageV2 { "UND_ERR_BODY_TIMEOUT", ]) + const echoPolicyCodes = new Set([ + "policy_violation", + "content_filter", + "content_policy", + "content_policy_violation", + "guardrail_intervened", + ]) + + function isEchoPolicyCode(code: string | undefined): boolean { + if (!code) return false + return echoPolicyCodes.has(code.toLowerCase()) || /^(safety|moderation)_.+$/i.test(code) + } + function echoPolicyCode(responseBody: string | undefined): string | undefined { if (!responseBody) return undefined const find = (value: unknown): string | undefined => { if (!value || typeof value !== "object") return undefined for (const [key, child] of Object.entries(value)) { - if (key === "code" && typeof child === "string" && /policy|content|safety|moderation/i.test(child)) return child + if (key === "code" && typeof child === "string" && isEchoPolicyCode(child)) return child const nested = find(child) if (nested) return nested } @@ -556,7 +569,7 @@ export namespace MessageV2 { const { statusCode, responseBody, metadata, isRetryable } = error.data // Provider decisions can be wrapped by a gateway with a transient HTTP status. // Inspect structured codes, never prose in a message or response body. - if (echoPolicyCode(responseBody) || /policy|content|safety|moderation/i.test(metadata?.code ?? "")) return false + if (echoPolicyCode(responseBody) || isEchoPolicyCode(metadata?.code)) return false if (statusCode !== undefined) return (statusCode >= 500 && statusCode < 600) || statusCode === 429 || statusCode === 408 const code = metadata?.code @@ -1258,17 +1271,17 @@ export namespace MessageV2 { providerID: ctx.providerID, error: e, }) - // altimate_change start — retain policy decisions before response truncation or overflow detection can hide them + // altimate_change start — retain policy decisions before response truncation without overriding context overflow const policyCode = echoPolicyCode(e.responseBody) - if (policyCode) { + if (parsed.type === "api_error" && policyCode) { return new MessageV2.APIError( { message: parsed.message, statusCode: e.statusCode, - isRetryable: parsed.type === "api_error" ? parsed.isRetryable : e.isRetryable, - responseHeaders: parsed.type === "api_error" ? parsed.responseHeaders : e.responseHeaders, + isRetryable: parsed.isRetryable, + responseHeaders: parsed.responseHeaders, responseBody: parsed.responseBody, - metadata: { ...(parsed.type === "api_error" ? parsed.metadata : undefined), code: policyCode }, + metadata: { ...parsed.metadata, code: policyCode }, }, { cause: e }, ).toObject() diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 5ddbe6478b..4f60e2ae45 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -249,6 +249,9 @@ export namespace SessionProcessor { // altimate_change start — keep nudge delivery scoped to the active prompt generation nudgeGeneration?: NudgeArbiter.Generation // altimate_change end + // altimate_change start - recover summarizer failures only when the prompt retains this request's committed answer + recoverCommittedAnswer?: boolean + // altimate_change end }) { // altimate_change start — Map (not plain object) so adversarial ids can // never resolve to inherited Object.prototype members. @@ -331,6 +334,9 @@ export namespace SessionProcessor { // altimate_change end log.info("process") needsCompaction = false + // altimate_change start - let the prompt's exit guard publish a recovered compaction answer + let recoveredCommittedAnswer = false + // altimate_change end // altimate_change start — resolve breaker config + arm state once per step. // ANNOTATE-ONLY by default (mode "annotate"): directives and the hard stop // require mode "armed" AND run mode. Skipped entirely for plan/review-class @@ -1097,7 +1103,9 @@ export namespace SessionProcessor { tokens: usage.tokens, cost: usage.cost, }) - await Session.updateMessage(input.assistantMessage) + // altimate_change start - buffered echoes publish authoritative text before a finished message + if (!deferText || input.assistantMessage.error) await Session.updateMessage(input.assistantMessage) + // altimate_change end // altimate_change start — capture the snapshot diff as the generic, // command-agnostic mutation ground truth (also catches bash-mediated // writes like `sed -i`/heredocs, which emit no edit event). @@ -1261,9 +1269,9 @@ export namespace SessionProcessor { // altimate_change end }) const error = MessageV2.fromError(e, { providerID: input.model.providerID }) - // altimate_change start — a buffered echo already has its answer; persist the cause without a fatal event + // altimate_change start — committed echoes and summaries retain recoverable causes without a fatal event if ( - deferText && + (deferText || (input.recoverCommittedAnswer && input.assistantMessage.summary)) && !input.abort.aborted && input.assistantMessage.finish !== "content-filter" && (MessageV2.isEchoTransportError(error) || MessageV2.ContextOverflowError.isInstance(error)) @@ -1272,6 +1280,7 @@ export namespace SessionProcessor { ...input.assistantMessage.metadata, answer_commit_echo_error: error, } + recoveredCommittedAnswer = input.recoverCommittedAnswer === true && input.assistantMessage.summary === true } else if (MessageV2.ContextOverflowError.isInstance(error)) { // altimate_change end if ((await Config.get()).compaction?.auto === false && !input.assistantMessage.summary) { @@ -1409,7 +1418,9 @@ export namespace SessionProcessor { } } input.assistantMessage.time.completed = Date.now() - await Session.updateMessage(input.assistantMessage) + // altimate_change start - the prompt finalizes buffered echoes after publishing their committed text + if (!deferText || input.assistantMessage.error) await Session.updateMessage(input.assistantMessage) + // altimate_change end // altimate_change start — explicit model DONE is the PRIMARY // termination path. A turn that finished with "stop", has no error, and // asserts completion (trailing DONE token per the SessionTermination @@ -1437,6 +1448,7 @@ export namespace SessionProcessor { // altimate_change start — the ordering itself lives in the exported // `resolveFinishOutcome` so the unit gate can exercise the REAL // decision instead of a hand-written copy of it (PR #1171 review). + if (recoveredCommittedAnswer) return "stop" as const return resolveFinishOutcome({ needsCompaction, explicitDone, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1812f9fec2..81005f2bac 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -928,8 +928,9 @@ export namespace SessionPrompt { for (const old of storedText) { if (old.id !== part.id) await Session.removePart({ sessionID, messageID: message.id, partID: old.id }) } + const recoveryCause = error ?? message.metadata?.answer_commit_echo_error const cause = - error ?? message.metadata?.answer_commit_echo_error ?? + recoveryCause ?? (message.finish !== "stop" ? { finish: message.finish ?? "unknown" } : undefined) if (cause) message.metadata = { ...message.metadata, answer_commit_echo_error: cause } delete message.error @@ -938,11 +939,13 @@ export namespace SessionPrompt { delete message.summary message.agent = "analyst" message.mode = "analyst" + await Session.updateMessage({ ...message, finish: undefined, time: { created: message.time.created } }) } - message.finish = "stop" + // Persist the authoritative text before making this message terminal on resume. + await publishText([part]) + message.finish = message.finish === "other" && !recoveryCause ? "other" : "stop" message.time.completed = Date.now() await Session.updateMessage(message) - await publishText([part]) committedAnswer = undefined return true } @@ -1305,6 +1308,7 @@ export namespace SessionPrompt { onContinuation: (messageID) => { compactionContinuations.add(messageID) }, + recoverCommittedAnswer: committedAnswer !== undefined, // altimate_change end // altimate_change start — keep nudge delivery scoped to this prompt generation nudgeGeneration, diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 0e4623a7ef..c0d804d5c0 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -23,6 +23,7 @@ import { SessionSummary } from "../../src/session/summary" import { APICallError } from "ai" import { SessionRetry } from "../../src/session/retry" import { Snapshot } from "../../src/snapshot" +import * as RunAccounting from "../../src/cli/cmd/run-accounting" // altimate_change end import { RuntimeFlags } from "../../src/effect/runtime-flags" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -362,14 +363,18 @@ for (const scenario of [ "echo-length", "echo-mismatch", "echo-no-text", + "echo-write-order", "echo-transport-error", "echo-socket-reset", "echo-policy-denied", + "echo-guardrail-denied", "echo-forbidden", "echo-snapshot-error", "echo-overflow-enabled", "echo-reactive-compaction", "compaction-error", + "compaction-forbidden", + "compaction-guardrail-denied", "compaction-aborted", "compaction-content-filter", "echo-overflow-disabled", @@ -408,14 +413,18 @@ for (const scenario of [ "echo-length": "a length-limited echo is replaced with the stored committed output verbatim", "echo-mismatch": "a mismatched echo delivers the committed output exactly once to run output and ACP", "echo-no-text": "a stop echo without text publishes the committed output as a completed timed text part", + "echo-write-order": "a committed echo persists its authoritative text before any finished message update", "echo-transport-error": "a 503 echo transport failure publishes the committed output without fatal events", "echo-socket-reset": "a socket reset during echo publishes the committed output without fatal events", "echo-policy-denied": "a 400 structured policy denial during echo emits fatal message and session errors", + "echo-guardrail-denied": "a 503 orchestration guardrail denial during echo remains fatal", "echo-forbidden": "a 403 echo failure retains its fatal error without publishing the committed output", "echo-snapshot-error": "a local snapshot failure before the echo request retains its fatal UnknownError", "echo-overflow-enabled": "a context overflow during echo publishes the committed output without fatal events", "echo-reactive-compaction": "an echo triggering reactive compaction publishes only the committed text without a continuation", - "compaction-error": "a failed compaction summariser after a commit publishes the committed output and records the cause", + "compaction-error": "a 503 compaction summariser failure after a commit publishes the committed output without fatal events", + "compaction-forbidden": "a 403 compaction summariser failure after a commit remains fatal", + "compaction-guardrail-denied": "a 503 orchestration guardrail denial during compaction after a commit remains fatal", "compaction-aborted": "an aborted compaction after a commit retains its fatal error without publishing the committed output", "compaction-content-filter": "a content-filtered compaction after a commit retains its fatal error without publishing the committed output", "echo-overflow-disabled": "an unrecoverable context overflow after a commit publishes the committed output and records the cause", @@ -423,7 +432,7 @@ for (const scenario of [ "echo-aborted": "an aborted echo retains its fatal error without publishing the committed output", "echo-content-filter": "a content-filtered echo retains its fatal error and original text", "echo-http-content-filter": "an HTTP content-filtered echo retains its fatal error without publishing the committed output", - "echo-other": "an echo with an other finish ends as stop and records the original cause", + "echo-other": "an echo with an other finish keeps other and publishes the committed output", "echo-exact-length": "an exact echo with a length finish ends normally with stop", "echo-capped": "a dispatch-capped commit preserves the full rendered answer in the final assistant text", "echo-under-cap": "a commit below the dispatch cap preserves the rendered answer in the final assistant text", @@ -521,8 +530,8 @@ for (const scenario of [ if ( step === 2 && [ - "echo-transport-error", "echo-policy-denied", "echo-forbidden", "echo-http-content-filter", - "compaction-error", "compaction-content-filter", "echo-overflow-disabled", "echo-overflow-enabled", + "echo-transport-error", "echo-policy-denied", "echo-guardrail-denied", "echo-forbidden", "echo-http-content-filter", + "compaction-error", "compaction-forbidden", "compaction-guardrail-denied", "compaction-content-filter", "echo-overflow-disabled", "echo-overflow-enabled", ].includes(scenario) ) { const error = @@ -533,10 +542,13 @@ for (const scenario of [ : scenario === "echo-overflow-disabled" || scenario === "echo-overflow-enabled" ? { code: "context_length_exceeded", message: "maximum context length exceeded" } : { message: compacting ? "Compaction summariser rejected the request" : "Echo transport rejected the request" } - return new Response(JSON.stringify({ error }), { - status: scenario === "echo-transport-error" || scenario === "compaction-error" + const guardrail = scenario === "echo-guardrail-denied" || scenario === "compaction-guardrail-denied" + return new Response(JSON.stringify(guardrail + ? { error, orchestrationError: { code: "guardrail_intervened" } } + : { error }), { + status: scenario === "echo-transport-error" || scenario === "compaction-error" || guardrail ? 503 - : scenario === "echo-forbidden" ? 403 : 400, + : scenario === "echo-forbidden" || scenario === "compaction-forbidden" ? 403 : 400, headers: { "Content-Type": "application/json" }, }) } @@ -638,9 +650,21 @@ for (const scenario of [ if (scenario === "echo-snapshot-error" && echoPrepared) throw new Error("Snapshot tracking failed") return track() }) - const retrySpy = scenario === "compaction-error" + const retrySpy = scenario === "compaction-error" || scenario === "echo-guardrail-denied" || scenario === "compaction-guardrail-denied" ? spyOn(SessionRetry, "retryable").mockReturnValue(undefined) : undefined + const finishedEchoText: string[][] = [] + const updateMessage = LegacySession.updateMessage + const finishSpy = scenario === "echo-write-order" + ? spyOn(LegacySession, "updateMessage").mockImplementation(Object.assign(async (message: MessageV2.Info) => { + if (message.role === "assistant" && requests.length === 2 && message.finish) { + finishedEchoText.push(MessageV2.parts(message.id) + .filter((part) => part.type === "text" && part.time?.end !== undefined) + .map((part) => (part as MessageV2.TextPart).text)) + } + return updateMessage(message) + }, updateMessage)) + : undefined const toModelMessages = MessageV2.toModelMessages const historySpy = spyOn(MessageV2, "toModelMessages").mockImplementation(async (...input) => { if (scenario === "echo-preparation-error" && requests.length === 1) @@ -651,6 +675,8 @@ for (const scenario of [ return messages }) const output = new OutputCollector() + const accounting = RunAccounting.create() + const compactionTextIDs: MessageID[] = [] const textUpdates: MessageV2.TextPart[] = [] const textPartIDs = new Set() const textDeltas: { messageID: MessageID; text: string }[] = [] @@ -668,23 +694,30 @@ for (const scenario of [ requestStarts.push(request.properties.messageID) if (event.type === "session.error" && event.properties.sessionID === chat.id) { sessionErrors.push(event.properties.error) + accounting.onSessionError(event.properties.error?.name) } if (event.type === MessageV2.Event.Updated.type) { const message = event.properties.info - if (message.sessionID === chat.id && message.role === "assistant") + if (message.sessionID === chat.id && message.role === "assistant") { assistantUpdates.push(structuredClone(message)) + accounting.onAssistantMessage(message) + } } if (event.type === MessageV2.Event.PartUpdated.type) { const part = event.properties.part as MessageV2.Part if (part.sessionID !== chat.id) return - if (part.type === "step-start") output.stepStart() + if (part.type === "step-start") output.stepStart(accounting.isCompactionStep(part.messageID)) if (part.type !== "text") return // Snapshot the published value: later mutations must not rewrite what a client observed. textUpdates.push(structuredClone(part)) textEvents.push({ messageID: part.messageID, type: part.time?.end ? "completion" : "start", text: part.text }) textPartIDs.add(part.id) // Match run.ts: only completed text parts are printed and collected for --output. - if (part.time?.end) output.text(part.text.trim(), { synthetic: part.synthetic === true }) + if (part.time?.end) { + const compaction = accounting.isCompactionStep(part.messageID) + if (compaction) compactionTextIDs.push(part.messageID) + output.text(part.text.trim(), { synthetic: part.synthetic === true, compaction }) + } } if ( event.type === MessageV2.Event.PartDelta.type && @@ -754,14 +787,18 @@ for (const scenario of [ return } const result = yield* pending + if (result.info.role === "assistant") accounting.onPromptResult(result.info) if ( scenario === "echo-snapshot-error" || scenario === "echo-policy-denied" || + scenario === "echo-guardrail-denied" || scenario === "echo-forbidden" || scenario === "echo-aborted" || scenario === "echo-content-filter" || scenario === "echo-http-content-filter" || scenario === "compaction-aborted" || + scenario === "compaction-forbidden" || + scenario === "compaction-guardrail-denied" || scenario === "compaction-content-filter" ) { expect(result.info).toHaveProperty( @@ -771,7 +808,8 @@ for (const scenario of [ : scenario === "echo-aborted" || scenario === "compaction-aborted" ? "MessageAbortedError" : scenario === "echo-http-content-filter" || scenario === "compaction-content-filter" || - scenario === "echo-policy-denied" || scenario === "echo-forbidden" + scenario === "echo-policy-denied" || scenario === "echo-guardrail-denied" || scenario === "echo-forbidden" || + scenario === "compaction-forbidden" || scenario === "compaction-guardrail-denied" ? "APIError" : "ContentFilterError", ) @@ -779,6 +817,7 @@ for (const scenario of [ expect(result.info).not.toHaveProperty("finish", "stop") expect(requests).toHaveLength(scenario === "echo-snapshot-error" ? 1 : 2) expect(sessionErrors).toHaveLength(1) + expect(accounting.fatal).toBe(true) expect(assistantUpdates.some((message) => message.id === result.info.id && message.error)).toBe(true) if (scenario !== "echo-content-filter") { expect(result.parts.filter((part) => part.type === "text")).toEqual([]) @@ -827,10 +866,10 @@ for (const scenario of [ ]) expect(output.content()).toBe(renderedAnswer) expect(requests).toHaveLength(2) - if (!compacting) { - expect(sessionErrors).toEqual([]) - expect(assistantUpdates.filter((message) => message.error)).toEqual([]) - } + expect(sessionErrors).toEqual([]) + expect(assistantUpdates.filter((message) => message.error)).toEqual([]) + expect(accounting.fatal).toBe(false) + expect(compactionTextIDs).not.toContain(result.info.id) const history = yield* sessions.messages({ sessionID: chat.id }) if (compacting) { expect(JSON.stringify(requests[1].messages)).toContain("Provide a detailed prompt for continuing") @@ -920,8 +959,13 @@ for (const scenario of [ expect(last.content).toBe(echoInstruction) } const expectedText = renderedAnswer - expect(result.info).toHaveProperty("finish", "stop") + expect(result.info).toHaveProperty("finish", scenario === "echo-other" ? "other" : "stop") if (scenario === "echo-other") expect(result.info).toHaveProperty("metadata.answer_commit_echo_error.finish", "other") + if (scenario === "echo-other") expect(accounting.fatal).toBe(true) + if (scenario === "echo-write-order") { + expect(finishedEchoText.length).toBeGreaterThan(0) + expect(finishedEchoText.every((texts) => texts.length === 1 && texts[0] === renderedAnswer)).toBe(true) + } expect(result.parts).toContainEqual(expect.objectContaining({ type: "text", text: expectedText })) if (scenario === "echo-no-text") { const part = result.parts.find((part) => part.type === "text") @@ -999,6 +1043,7 @@ for (const scenario of [ } finally { GlobalBus.off("event", collect) commitSpy?.mockRestore() + finishSpy?.mockRestore() retrySpy?.mockRestore() snapshotSpy.mockRestore() historySpy.mockRestore() @@ -1047,7 +1092,10 @@ describe("committed-answer echo error classification", () => { expect(MessageV2.isEchoTransportError(error)).toBe(false) }) } - for (const code of ["policy_violation", "content_filter", "safety_rejection", "moderation_blocked"]) { + for (const code of [ + "policy_violation", "content_filter", "content_policy", "content_policy_violation", + "guardrail_intervened", "safety_rejection", "moderation_blocked", + ]) { test(`structured ${code} overrides an otherwise recoverable HTTP status`, () => { const error = new MessageV2.APIError({ message: "Request denied", @@ -1070,7 +1118,7 @@ describe("committed-answer echo error classification", () => { expect(error.name).toBe("APIError") expect(MessageV2.isEchoTransportError(error)).toBe(false) }) - test("a structured policy decision with overflow-looking text stays a fatal API error", () => { + test("a structured policy code never overrides an existing context overflow classification", () => { const error = MessageV2.fromError(new APICallError({ message: "maximum context length exceeded", url: "https://example.com/v1/chat/completions", @@ -1079,9 +1127,33 @@ describe("committed-answer echo error classification", () => { isRetryable: false, responseBody: JSON.stringify({ error: { code: "policy_violation", message: "maximum context length exceeded" } }), }), { providerID: promptModel.providerID }) + expect(error.name).toBe("ContextOverflowError") + expect(MessageV2.isEchoTransportError(error)).toBe(false) + }) + test("a 503 orchestration guardrail code survives normalization and vetoes echo recovery", () => { + const error = MessageV2.fromError(new APICallError({ + message: "Request denied", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 503, + isRetryable: true, + responseBody: JSON.stringify({ orchestrationError: { code: "guardrail_intervened" } }), + }), { providerID: promptModel.providerID }) expect(error.name).toBe("APIError") expect(MessageV2.isEchoTransportError(error)).toBe(false) }) + test("a 413 content_length_exceeded code remains a context overflow", () => { + const error = MessageV2.fromError(new APICallError({ + message: "Request entity too large", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 413, + isRetryable: false, + responseBody: JSON.stringify({ error: { code: "content_length_exceeded" } }), + }), { providerID: promptModel.providerID }) + expect(error.name).toBe("ContextOverflowError") + expect(MessageV2.isEchoTransportError(error)).toBe(false) + }) test("a wrapped socket reset without a status remains recoverable even when the SDK disables retries", () => { const error = MessageV2.fromError(new APICallError({ message: "Connection interrupted", diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5e05207808..e21f963371 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1296,6 +1296,72 @@ it.instance("loop surfaces content-filter finishes as session errors", () => ) }), ) +// altimate_change start - provider length codes must retain the automatic compaction path +noLLMServer.instance( + "HTTP 413 content_length_exceeded stays a context overflow and runs compaction without a commit", + () => + Effect.gen(function* () { + const { prompt, chat, sessions } = yield* boot() + const requests: Record[] = [] + const summaryText = "The user requested a count. Continue after compacting the conversation." + const language = createOpenAICompatible({ + name: "test", + baseURL: "https://unused.invalid/v1", + fetch: Object.assign( + async (_url: string | URL | Request, init?: RequestInit) => { + if (typeof init?.body !== "string") throw new Error("expected a JSON request body") + requests.push(JSON.parse(init.body)) + if (requests.length > 3) throw new Error("unexpected request after overflow recovery") + if (requests.length === 1) { + return new Response( + JSON.stringify({ error: { code: "content_length_exceeded", message: "request entity too large" } }), + { status: 413, headers: { "Content-Type": "application/json" } }, + ) + } + const response = reply().text(requests.length === 2 ? summaryText : "42").stop().item() + if (response.type !== "sse") throw new Error("expected SSE response") + return new Response( + [...response.head, ...response.tail].map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + + "data: [DONE]\n\n", + { headers: { "Content-Type": "text/event-stream" } }, + ) + }, + { preconnect: () => undefined }, + ), + }).chatModel("test-model") + const languageSpy = spyOn(ProviderSvc, "getLanguage").mockResolvedValue(language) + const classify = spyOn(MessageV2, "fromError") + try { + const result = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: promptRef, + parts: [{ type: "text", text: "How many rows?" }], + }) + expect(classify.mock.results.map((result) => result.value)).toContainEqual( + expect.objectContaining({ name: "ContextOverflowError" }), + ) + expect(result.info).not.toHaveProperty("error") + expect(result.info).toHaveProperty("finish", "stop") + expect(result.parts.filter((part) => part.type === "text").map((part) => part.text).join("")).toBe("42") + expect(requests).toHaveLength(3) + expect(JSON.stringify(requests[2].messages)).toContain(summaryText) + const messages = yield* sessions.messages({ sessionID: chat.id }) + expect(messages.filter((message) => message.info.role === "assistant" && message.info.summary)).toHaveLength(1) + expect(messages.flatMap((message) => message.parts)).toContainEqual( + expect.objectContaining({ type: "compaction", overflow: true }), + ) + expect(messages.flatMap((message) => message.parts).some((part) => part.type === "tool" && part.tool === "answer_commit")).toBe(false) + } finally { + classify.mockRestore() + languageSpy.mockRestore() + } + }), + { config: { ...cfg, snapshot: false, learn: { enabled: false }, compaction: { auto: true } } }, + 30_000, +) +// altimate_change end + it.instance("loop stops provider overflow instead of auto-compacting when disabled", () => Effect.gen(function* () { const { llm } = yield* useServerConfig((url) => ({ From 12f8c876a5bbc8122151de3526a7fcf0c43490ef Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 22:25:05 -0700 Subject: [PATCH 25/39] feat(analyst): `rows.format` for tabular answers; recover the committed answer when compaction yields no summary - `answer_commit`: `rows.format` selects `markdown` (default), `csv`, `tsv` or `lines`. CSV/TSV include the header row, quote cells containing the delimiter, quotes or newlines (RFC 4180, embedded quotes doubled), render null as empty and honour `rows.precision`; `lines` emits one row per line with cells separated by one space and no header. With `answer_only` the output is exactly the formatted table. The tool description tells the model to set the format when the request names one. - compaction: two textless summariser responses after a completed `answer_commit` for the current request record the cause as `answer_commit_echo_error` metadata instead of a fatal error, so the guard publishes the committed text; without a commit the existing `UnknownError` is unchanged. - tests: csv/tsv quoting, lines, precision in csv, markdown default, schema still draft-2020-12 valid, rendered-size cap after quoting; committed and uncommitted empty-summary compaction paths. Co-Authored-By: Claude Fable 5.1 --- .../src/altimate/tools/answer-commit.ts | 33 +++-- packages/opencode/src/session/compaction.ts | 15 ++- .../opencode/test/agent/answer-commit.test.ts | 53 ++++++-- .../test/altimate/tools/answer-commit.test.ts | 117 ++++++++++++++++++ 4 files changed, 198 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 0b612e1435..5db4a2a2a1 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -52,6 +52,11 @@ const field = z const rows = z .object({ columns: z.array(z.string()).min(1, "rows.columns requires at least one column"), + // altimate_change start - preserve the tabular format requested in text mode + format: optional(z.enum(["markdown", "csv", "tsv", "lines"])).describe( + "Tabular output format: markdown (default), csv, tsv, or lines. CSV/TSV include headers and quote delimiters, quotes, and newlines; null cells are empty. Lines omits headers and separates cells with one space for single-column or two-column plain lists.", + ), + // altimate_change end values: z .array(z.array(z.union([z.string(), number, z.null()]))) .min(1, "rows.values requires at least one row") @@ -209,20 +214,33 @@ export function renderAnswer(payload: unknown): string { return limitRenderedAnswer(args.answer_only ? values : `${basis}**Answer:** ${values}`) } if (args.rows) { - const { columns, values, precision } = args.rows - const row = (cells: (string | number | null)[]) => `| ${cells.map(tableCell).join(" | ")} |` + // altimate_change start - render the requested format before applying the shared size cap + const { columns, values, precision, format = "markdown" } = args.rows + const row = (cells: (string | number | null)[]) => { + if (format === "markdown") return `| ${cells.map(tableCell).join(" | ")} |` + if (format === "lines") return cells.map(String).join(" ") + const delimiter = format === "csv" ? "," : "\t" + return cells + .map((value) => { + const cell = value === null ? "" : String(value) + return cell.includes(delimiter) || /["\r\n]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell + }) + .join(delimiter) + } const table = [ - row(columns), - `|${columns.map(() => "---").join("|")}|`, + ...(format === "lines" ? [] : [row(columns)]), + ...(format === "markdown" ? [`|${columns.map(() => "---").join("|")}|`] : []), ...values.map((cells) => row( cells.map((value, index) => { - const places = precision && Object.hasOwn(precision, columns[index]!) ? precision[columns[index]!] : undefined + const places = + precision && Object.hasOwn(precision, columns[index]!) ? precision[columns[index]!] : undefined return typeof value === "number" && places !== undefined ? value.toFixed(places) : value }), ), ), ].join("\n") + // altimate_change end return limitRenderedAnswer( args.answer_only ? table : `${basis}**Answer:**\n${table}${args.rows.note ? `\n${args.rows.note}` : ""}`, ) @@ -242,8 +260,9 @@ const Parameters = Schema.declareConstructor, Answer export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ - description: - `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Numbers are accepted up to 15 significant digits; any decimal that must be reproduced exactly (more digits, trailing zeros, or currency strings) must be passed as a string. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, + // altimate_change start - expose format selection to the model before the terminal tool call + description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Numbers are accepted up to 15 significant digits; any decimal that must be reproduced exactly (more digits, trailing zeros, or currency strings) must be passed as a string. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, + // altimate_change end parameters: Parameters, formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)), // Preserve the existing wire schema; Zod and the SDK use different recursive JSON Schema types. diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index aedb3e30f8..d2f9d05cf7 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1596,7 +1596,8 @@ When constructing the summary, try to stick to this template: // A "continue" result was previously committed regardless of whether the // summary step produced any text — an empty summary erases history (the // post-compaction amnesia signature). Guard the commit: retry ONCE with - // identical input, then mark the summary message as errored and stop. + // identical input, then stop with a recoverable cause for a committed answer + // or mark the summary message as errored when there is no committed answer. const summaryHasText = () => MessageV2.get({ sessionID: input.sessionID, messageID: msg.id }).parts.some( (part) => part.type === "text" && part.text.trim().length > 0, @@ -1606,10 +1607,18 @@ When constructing the summary, try to stick to this template: log.warn("compaction summary empty, retrying once", { sessionID: input.sessionID }) result = await processor.process(summarizerInput) if (result === "continue" && !summaryHasText()) { - processor.message.error = new NamedError.Unknown({ + const error = new NamedError.Unknown({ message: "Compaction summarizer produced no summary text after retry", }).toObject() - processor.message.finish = "error" + if (input.recoverCommittedAnswer && !input.abort.aborted) { + processor.message.metadata = { + ...processor.message.metadata, + answer_commit_echo_error: error, + } + } else { + processor.message.error = error + processor.message.finish = "error" + } await Session.updateMessage(processor.message) result = "stop" } diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index c0d804d5c0..15b7b24945 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -373,6 +373,8 @@ for (const scenario of [ "echo-overflow-enabled", "echo-reactive-compaction", "compaction-error", + "compaction-empty-summary", + "compaction-empty-summary-without-commit", "compaction-forbidden", "compaction-guardrail-denied", "compaction-aborted", @@ -404,6 +406,7 @@ for (const scenario of [ const priorCommit = scenario === "json_schema-history" const tinyCap = scenario === "echo-capped" || scenario === "echo-under-cap" const compacting = scenario.startsWith("compaction-") + const emptySummary = scenario.startsWith("compaction-empty-summary") const userDuringEcho = scenario.startsWith("user-during-") const queuedUser = scenario === "queued-user" || scenario === "queued-user-reversed-ids" const userAfterCommit = scenario === "new-user-after-commit-reversed-ids" @@ -423,6 +426,8 @@ for (const scenario of [ "echo-overflow-enabled": "a context overflow during echo publishes the committed output without fatal events", "echo-reactive-compaction": "an echo triggering reactive compaction publishes only the committed text without a continuation", "compaction-error": "a 503 compaction summariser failure after a commit publishes the committed output without fatal events", + "compaction-empty-summary": "two empty compaction summaries after a commit publish the committed output without fatal events", + "compaction-empty-summary-without-commit": "two empty compaction summaries without a commit retain the existing UnknownError", "compaction-forbidden": "a 403 compaction summariser failure after a commit remains fatal", "compaction-guardrail-denied": "a 503 orchestration guardrail denial during compaction after a commit remains fatal", "compaction-aborted": "an aborted compaction after a commit retains its fatal error without publishing the committed output", @@ -524,7 +529,7 @@ for (const scenario of [ async (_url: string | URL | Request, init?: RequestInit) => { if (typeof init?.body !== "string") throw new Error("expected a JSON request body") requests.push(JSON.parse(init.body)) - if (requests.length > (priorCommit ? 4 : userDuringEcho ? 3 : 2)) + if (requests.length > (priorCommit ? 4 : userDuringEcho || emptySummary ? 3 : 2)) throw new Error("unexpected extra generation") const step = priorCommit && requests.length > 2 ? requests.length - 2 : requests.length if ( @@ -571,12 +576,14 @@ for (const scenario of [ ? reply().tool("answer_commit", args).tool("tool_lookup", { tool_name: "__missing_tool" }).item() : scenario === "failed-after-success" ? reply().tool("answer_commit", args).tool("answer_commit", {}).item() - : reply() - .tool("answer_commit", scenario === "failed" ? {} : args) - .usage({ input: compacting ? 95000 : 0, output: 1 }) - .item() - : scenario === "echo-no-text" - ? reply().stop().item() + : scenario === "compaction-empty-summary-without-commit" + ? reply().tool("tool_lookup", { tool_name: "__missing_tool" }).usage({ input: 95000, output: 1 }).item() + : reply() + .tool("answer_commit", scenario === "failed" ? {} : args) + .usage({ input: compacting ? 95000 : 0, output: 1 }) + .item() + : emptySummary || scenario === "echo-no-text" + ? (emptySummary ? reply().text("") : reply()).stop().item() : structured && (!priorCommit || requests.length > 2) ? reply().tool("StructuredOutput", { count: 42 }).item() : reply() @@ -788,6 +795,28 @@ for (const scenario of [ } const result = yield* pending if (result.info.role === "assistant") accounting.onPromptResult(result.info) + if (emptySummary) { + expect(requests).toHaveLength(3) + expect(JSON.stringify(requests[1].messages)).toContain("Provide a detailed prompt for continuing") + expect(requests[2].messages).toEqual(requests[1].messages) + expect(requests.slice(1).map((request) => request.tools ?? [])).toEqual([[], []]) + if (scenario === "compaction-empty-summary-without-commit") { + expect(result.info).toHaveProperty("error.name", "UnknownError") + expect(result.info).toHaveProperty( + "error.data.message", + "Compaction summarizer produced no summary text after retry", + ) + expect(result.info).toHaveProperty("finish", "error") + expect(result.info).not.toHaveProperty("metadata.answer_commit_echo_error") + expect(result.parts.filter((part) => part.type === "text")).toEqual([]) + expect(accounting.fatal).toBe(true) + const history = yield* sessions.messages({ sessionID: chat.id }) + expect(history.flatMap((message) => message.parts).filter( + (part) => part.type === "tool" && part.tool === "answer_commit", + )).toEqual([]) + return + } + } if ( scenario === "echo-snapshot-error" || scenario === "echo-policy-denied" || @@ -848,13 +877,17 @@ for (const scenario of [ return } if ( - scenario === "compaction-error" || scenario === "echo-overflow-disabled" || scenario === "echo-overflow-enabled" + scenario === "compaction-error" || scenario === "compaction-empty-summary" || + scenario === "echo-overflow-disabled" || scenario === "echo-overflow-enabled" ) { const cause = scenario === "compaction-error" ? "Compaction summariser rejected the request" - : "maximum context length exceeded" + : emptySummary + ? "Compaction summarizer produced no summary text after retry" + : "maximum context length exceeded" expect(result.info).toHaveProperty("metadata.answer_commit_echo_error.data.message", cause) + if (emptySummary) expect(result.info).toHaveProperty("metadata.answer_commit_echo_error.name", "UnknownError") expect(result.info).toHaveProperty("finish", "stop") expect(result.info).not.toHaveProperty("summary", true) expect(result.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual([renderedAnswer]) @@ -865,7 +898,7 @@ for (const scenario of [ renderedAnswer, ]) expect(output.content()).toBe(renderedAnswer) - expect(requests).toHaveLength(2) + expect(requests).toHaveLength(emptySummary ? 3 : 2) expect(sessionErrors).toEqual([]) expect(assistantUpdates.filter((message) => message.error)).toEqual([]) expect(accounting.fatal).toBe(false) diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index e305afe985..d8222a1ef3 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -176,6 +176,123 @@ test("answer_commit executes synchronously with native Effect permissions", asyn const recordBasis = "Used all records at record grain from records.value." +// altimate_change start - preserve the requested tabular output format +describe("answer_commit row formats", () => { + test.each([ + { + name: "csv quotes commas, quotes and newlines and includes headers", + rows: { + format: "csv", + columns: ['Name, "label"', "Value"], + values: [ + ['West, "Co"', null], + ["line\r\nbreak", " 001.20|\\ "], + ], + }, + expected: '"Name, ""label""",Value\n"West, ""Co""",\n"line\r\nbreak", 001.20|\\ ', + }, + { + name: "tsv quotes tabs, quotes and newlines and includes headers", + rows: { + format: "tsv", + columns: ["Name\tlabel", "Value"], + values: [ + ["West\tCo", null], + ['a"b', "line\nbreak"], + ["line\rbreak", "comma, unchanged"], + ], + }, + expected: '"Name\tlabel"\tValue\n"West\tCo"\t\n"a""b"\t"line\nbreak"\n"line\rbreak"\tcomma, unchanged', + }, + { + name: "lines renders a single-column list without headers", + rows: { format: "lines", columns: ["Name"], values: [["West"], ["East"]] }, + expected: "West\nEast", + }, + { + name: "lines separates two-column cells with one space", + rows: { + format: "lines", + columns: ["Name", "Count"], + values: [ + ["West|\\", 5], + ["East", "001.20"], + ["Missing", null], + ], + precision: { Count: 2 }, + }, + expected: "West|\\ 5.00\nEast 001.20\nMissing null", + }, + { + name: "csv applies precision to numeric cells and preserves strings", + rows: { + format: "csv", + columns: ["Count", "Rate", "Unspecified"], + values: [ + [5, 1.234, 2.5], + ["005.00", "1.2300", null], + ], + precision: { Count: 2, Rate: 1 }, + }, + expected: "Count,Rate,Unspecified\n5.00,1.2,2.5\n005.00,1.2300,", + }, + { + name: "default remains markdown", + rows: { + columns: ["Name", "Count"], + values: [ + ["West|\\", 5], + ["East", null], + ], + }, + expected: "| Name | Count |\n|---|---|\n| West\\|\\\\ | 5 |\n| East | null |", + }, + { + name: "explicit markdown retains table rendering", + rows: { format: "markdown", columns: ["Count"], values: [[5]], precision: { Count: 2 } }, + expected: "| Count |\n|---|\n| 5.00 |", + }, + ])("$name", async ({ rows, expected }) => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + for (const answer_only of [true, false]) { + const payload = { basis: recordBasis, answer_only, rows: { ...rows, note: "Result note." } } + const output = answer_only ? expected : `${recordBasis}\n\n**Answer:**\n${expected}\nResult note.` + expect(AnswerCommit.renderAnswer(payload)).toBe(output) + expect((await tool.execute(payload, context)).output).toBe(output) + } + }) + + test("description and schema advertise the tabular formats", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect(tool.description).toMatch(/when the request names a tabular format, set rows.format accordingly/i) + expect(structuredClone(tool.jsonSchema?.properties?.rows)).toMatchObject({ + anyOf: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + format: expect.objectContaining({ + anyOf: expect.arrayContaining([{ type: "string", enum: ["markdown", "csv", "tsv", "lines"] }]), + description: expect.stringMatching(/markdown.*default.*csv.*tsv.*lines/i), + }), + }), + }), + ]), + }) + }) + + test.each(["csv", "tsv", "lines"])("%s retains the rendered-size cap", async (format) => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + const payload = { + basis: recordBasis, + answer_only: true, + rows: { format, columns: ["Value"], values: [['"'.repeat(format === "lines" ? 60_001 : 30_000)]] }, + } + const error = /60,000 characters.*aggregate.*narrow/i + expect(() => AnswerCommit.renderAnswer(payload)).toThrow(error) + await rejects(tool.execute(payload, context), error) + }) +}) +// altimate_change end + describe("answer_commit numeric safety", () => { test("description explains numeric precision and exact decimal strings", async () => { const tool = await initTool(AnswerCommit.AnswerCommitTool) From 56602cd6eb9041f663e4295130b27a51a597d1d3 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 22:34:09 -0700 Subject: [PATCH 26/39] feat(analyst): one `answer_commit` field per item the request asks for A compound request ("the repository and the most copied file") was committed as a single field under `answer_only`, so the second item never appeared and the terminal tool could not repair it. - `answer_commit` description: supply one field per requested item, in the order asked; never collapse a compound request into one field or add fields the request did not ask for - `fields` schema description states the same contract; validation unchanged (field count is not checkable without the request text) - analyst prompt: the final paragraph asks for one field per item the request asks for; otherwise byte-identical - tests: description and schema contract; prompt contract in `sql-validation-e2e.test.ts` Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/altimate/prompts/analyst.txt | 2 +- packages/opencode/src/altimate/tools/answer-commit.ts | 4 ++-- .../opencode/test/altimate/sql-validation-e2e.test.ts | 4 +++- .../test/altimate/tools/answer-commit.test.ts | 11 +++++++++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index 02a1cc1825..83a38e519b 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -20,4 +20,4 @@ Use `training_list` to review learned knowledge. Skills: /teach, /train, /traini When the request supplies an output JSON schema (the `StructuredOutput` tool is available), finish with `StructuredOutput` instead of `answer_commit` or a plain-text reply; follow that schema. The text finalization rules below apply when no output schema is supplied. -Finish by calling `answer_commit` with the single chosen value(s), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Always supply `basis`, even when unresolved; it is never shown when the request asks for only the answer. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. +Finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for, a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Always supply `basis`, even when unresolved; it is never shown when the request asks for only the answer. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 5db4a2a2a1..ebf1127ae3 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -115,7 +115,7 @@ const parameters = z } }) }), - ), + ).describe("One entry per item the request asks for, in the order asked."), rows: optional(rows), unresolved: optional( z @@ -261,7 +261,7 @@ export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ // altimate_change start - expose format selection to the model before the terminal tool call - description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Numbers are accepted up to 15 significant digits; any decimal that must be reproduced exactly (more digits, trailing zeros, or currency strings) must be passed as a string. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, + description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Supply one field per item the request asks for, in the order asked: a request for 'the repository and the most copied file' needs two fields (one named for the repository, one for the file); never collapse a compound request into one field, and never add fields the request did not ask for. Numbers are accepted up to 15 significant digits; any decimal that must be reproduced exactly (more digits, trailing zeros, or currency strings) must be passed as a string. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, // altimate_change end parameters: Parameters, formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)), diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index 8832255df6..9e61a73cab 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -104,9 +104,11 @@ describe("Tool name consistency in prompts", () => { test("analyst prompt requires deterministic answer finalization", async () => { const prompt = await Bun.file(path.join(import.meta.dir, "../../src/altimate/prompts/analyst.txt")).text() const paragraph = prompt.trim().split("\n\n").at(-1) + // altimate_change start - require one field for each requested answer item expect(paragraph).toContain( - "Finish by calling `answer_commit` with the single chosen value(s), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else.", + "Finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for, a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else.", ) + // altimate_change end expect(paragraph).toContain("Always supply `basis`, even when unresolved") expect(paragraph).toContain("it is never shown when the request asks for only the answer") expect(paragraph).toContain("Never list excluded candidates or alternative answers") diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index d8222a1ef3..0d371de6c6 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -992,6 +992,17 @@ test("answer_commit description defines final-call timing and exact reply", asyn expect(tool.description).toContain("reply with exactly the returned text and nothing else") }) +// altimate_change start - preserve every requested item in compound answers +test("answer_commit description and fields schema require one field per requested item", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + expect(tool.description).toContain("one field per item the request asks for") + expect(tool.description).toContain("never collapse a compound request") + expect(structuredClone(tool.jsonSchema?.properties?.fields)).toMatchObject({ + description: "One entry per item the request asks for, in the order asked.", + }) +}) +// altimate_change end + test("answer_commit description defines answer_only and unused properties", async () => { const tool = await initTool(AnswerCommit.AnswerCommitTool) expect(tool.description).toContain("only the answer or a specific format") From 09e9581d573cc1ff4fe804ff1931702feadfac9d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 12 Oct 2026 23:02:24 -0700 Subject: [PATCH 27/39] fix(analyst): publish a recovered committed answer on a fresh text part when the summary already streamed A compaction summariser that streams partial text and then fails recoverably had already sent deltas for its text part; reusing that part id published the committed answer as another delta on it, and clients that do not treat a blank update as a reset showed the partial summary concatenated with the answer. - `finishCommittedAnswer` reuses a part id only for buffered (`deferText`) echo text; when the stored text was streamed it removes every stored text part and publishes under a fresh id - `run --output`: the collector tracks part ids and drops removed parts from the reply, the previous reply and the fallback; `run.ts` forwards `message.part.removed` - ACP: compaction-summary message text is no longer forwarded as agent chunks (ACP cannot retract chunks); clearing the summary flag on recovery lets the committed delta through exactly once; subscriptions that join mid-stream pick the flag up from the fetched message - tests: streamed partial summary + 503, and two empty summaries after streamed text, each deliver exactly the committed text to ACP and run output; collector removal cases; ordinary buffered echo still reuses its id and publishes once Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/acp/event.ts | 18 +++ packages/opencode/src/cli/cmd/run-output.ts | 31 +++- packages/opencode/src/cli/cmd/run.ts | 6 + packages/opencode/src/session/prompt.ts | 4 +- .../opencode/test/agent/answer-commit.test.ts | 147 +++++++++++++++++- 5 files changed, 194 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/acp/event.ts b/packages/opencode/src/acp/event.ts index 7d05fa6ee5..2b02ef6cb5 100644 --- a/packages/opencode/src/acp/event.ts +++ b/packages/opencode/src/acp/event.ts @@ -40,6 +40,9 @@ export class Subscription { private readonly abort = new AbortController() private readonly shellSnapshots = new Map() private readonly toolStarts = new Set() + // altimate_change start — ACP chunks cannot retract provisional compaction text + private readonly summaryMessages = new Set() + // altimate_change end private readonly permission: ACPPermission.Handler private started = false @@ -67,6 +70,15 @@ export class Subscription { async handle(event: Event) { switch (event.type) { + // altimate_change start — recovery clears summary before publishing its authoritative answer + case "message.updated": { + const message = event.properties.info + if (message.role !== "assistant") return + if (message.summary) this.summaryMessages.add(message.id) + else this.summaryMessages.delete(message.id) + return + } + // altimate_change end case "permission.asked": this.permission.handle(event) return @@ -169,6 +181,9 @@ export class Subscription { : await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID) if (metadata?.role !== "assistant") return if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) { + // altimate_change start — internal summaries may be removed or replaced after streaming + if (this.summaryMessages.has(props.messageID)) return + // altimate_change end await this.input.connection.sessionUpdate({ sessionId: session.id, update: { @@ -218,6 +233,9 @@ export class Subscription { } private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) { + // altimate_change start — cover subscriptions that join after the initial message update + if (message.info.role === "assistant" && message.info.summary) this.summaryMessages.add(message.info.id) + // altimate_change end return await Effect.runPromise( this.input.session.recordPartMetadata({ sessionId, diff --git a/packages/opencode/src/cli/cmd/run-output.ts b/packages/opencode/src/cli/cmd/run-output.ts index f0fada88b8..8aef8481c4 100644 --- a/packages/opencode/src/cli/cmd/run-output.ts +++ b/packages/opencode/src/cli/cmd/run-output.ts @@ -1,4 +1,4 @@ -// altimate_change — what `altimate-code run --output ` writes. +// altimate_change start — what `altimate-code run --output ` writes. // // The file is the answer, not the transcript. A model often narrates between tool // calls ("Mixed time formats. Let me check all formats.") and that text is superseded @@ -14,10 +14,12 @@ export const NO_TEXT_OUTPUT = "(no text output — tool-only response)" // token when the deliverable is already complete; that acknowledgement is not a new reply. const ACKNOWLEDGEMENT = new RegExp(`^${DONE_TOKEN}\\.?$`, "i") +type CollectedText = { text: string; partID?: string } + export class OutputCollector { - private readonly all: string[] = [] - private final: string[] = [] - private previous: string[] = [] + private all: CollectedText[] = [] + private final: CollectedText[] = [] + private previous: CollectedText[] = [] /** A new model step starts; text before it is no longer the reply. Compaction steps do not count. */ stepStart(compaction = false) { @@ -27,16 +29,24 @@ export class OutputCollector { } /** Completed assistant text. Synthetic/compaction text is kept for the fallback only. */ - text(text: string, opts: { synthetic?: boolean; compaction?: boolean } = {}) { + text(text: string, opts: { synthetic?: boolean; compaction?: boolean; partID?: string } = {}) { const trimmed = text.trim() if (!trimmed) return - this.all.push(trimmed) + const part = { text: trimmed, partID: opts.partID } + this.all.push(part) if (opts.synthetic || opts.compaction) return if (ACKNOWLEDGEMENT.test(trimmed) && this.final.length === 0) { this.final = this.previous return } - this.final.push(trimmed) + this.final.push(part) + } + + /** Removed text must disappear from the reply and every fallback. */ + remove(partID: string) { + this.all = this.all.filter((part) => part.partID !== partID) + this.final = this.final.filter((part) => part.partID !== partID) + this.previous = this.previous.filter((part) => part.partID !== partID) } private readonly notes: string[] = [] @@ -51,7 +61,12 @@ export class OutputCollector { * an answer and would read as one); otherwise everything that was said. */ content(): string { - const chosen = this.final.length ? this.final : this.notes.length ? this.notes : this.all + const chosen = this.final.length + ? this.final.map((part) => part.text) + : this.notes.length + ? this.notes + : this.all.map((part) => part.text) return chosen.join("\n\n") || NO_TEXT_OUTPUT } } +// altimate_change end diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 599d9391d6..96abb489b9 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1028,6 +1028,11 @@ You are speaking to a non-technical business executive. Follow these rules stric toggles.set("start", true) } + // altimate_change start — discarded streamed text must not survive in --output fallbacks + if (event.type === "message.part.removed" && event.properties.sessionID === sessionID) { + outputParts.remove(event.properties.partID) + } + // altimate_change end if (event.type === "message.part.updated") { const part = event.properties.part if (part.sessionID !== sessionID) continue @@ -1171,6 +1176,7 @@ You are speaking to a non-technical business executive. Follow these rules stric const text = part.text.trim() if (args.output && text) { outputParts.text(text, { + partID: part.id, synthetic: part.synthetic === true, compaction: accounting.isCompactionStep(part.messageID), }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 81005f2bac..a1db049a35 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -916,7 +916,9 @@ export namespace SessionPrompt { // Once the echo has started, this answer still belongs to the earlier request. // Publish it before the loop continues to any user request queued during generation. const storedText = MessageV2.parts(message.id).filter((part) => part.type === "text") - const first = deferredText[0] ?? storedText[0] + // Only buffered parts are safe to reuse: stored parts may already have + // streamed deltas, and a blank update does not reset client text. + const first = deferredText[0] const part: MessageV2.TextPart = { id: first?.id ?? PartID.ascending(), sessionID, diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 15b7b24945..7148190d83 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -24,6 +24,9 @@ import { APICallError } from "ai" import { SessionRetry } from "../../src/session/retry" import { Snapshot } from "../../src/snapshot" import * as RunAccounting from "../../src/cli/cmd/run-accounting" +import { ACPEvent } from "../../src/acp/event" +import { ACPSession } from "../../src/acp/session" +import type { Event as ClientEvent, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" // altimate_change end import { RuntimeFlags } from "../../src/effect/runtime-flags" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -358,6 +361,57 @@ describe("answer_commit agent scope", () => { }) // altimate_change start — committed-answer echo transport and delivery regressions +// Replay immutable bus events through the real ACP handler with the message state +// available at each event, rather than letting later recovery rewrite its lookup. +const acpText = (events: ClientEvent[], sessionID: string) => Effect.gen(function* () { + const session = yield* ACPSession.Service + yield* session.create({ id: sessionID, cwd: process.cwd() }) + const messages = new Map() + const chunks: string[] = [] + const subscription = new ACPEvent.Subscription({ + session, + sdk: { session: { message: async ({ messageID }: { messageID: string }) => ({ data: messages.get(messageID) }) } } as unknown as OpencodeClient, + connection: { + sessionUpdate: async ({ update }) => { + if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") chunks.push(update.content.text) + }, + }, + }) + for (const event of events) { + if (event.type === "message.updated") { + const info = event.properties.info + messages.set(info.id, { info, parts: messages.get(info.id)?.parts ?? [] }) + } + if (event.type === "message.part.updated") { + const part = event.properties.part + const message = messages.get(part.messageID) + if (message) message.parts = [...message.parts.filter((old) => old.id !== part.id), part] + } + if (event.type === "message.part.removed") { + const message = messages.get(event.properties.messageID) + if (message) message.parts = message.parts.filter((part) => part.id !== event.properties.partID) + } + yield* Effect.promise(() => subscription.handle(event)) + } + return chunks +}).pipe(Effect.provide(ACPSession.defaultLayer)) + +for (const location of ["reply", "previous", "compaction fallback"] as const) { + test(`run output drops removed text from the ${location} before delivering a committed answer`, () => { + const output = new OutputCollector() + output.text("Summary so far: ", { partID: "partial", compaction: location === "compaction fallback" }) + if (location === "previous") output.stepStart() + output.remove("partial") + expect(output.content()).toBe("(no text output — tool-only response)") + if (location === "previous") { + output.text("DONE", { partID: "acknowledgement" }) + expect(output.content()).toBe("DONE") + } + output.text("42", { partID: "committed" }) + expect(output.content()).toBe("42") + }) +} + for (const scenario of [ "text", "echo-length", @@ -373,7 +427,9 @@ for (const scenario of [ "echo-overflow-enabled", "echo-reactive-compaction", "compaction-error", + "compaction-streamed-error", "compaction-empty-summary", + "compaction-empty-summary-streamed", "compaction-empty-summary-without-commit", "compaction-forbidden", "compaction-guardrail-denied", @@ -407,6 +463,7 @@ for (const scenario of [ const tinyCap = scenario === "echo-capped" || scenario === "echo-under-cap" const compacting = scenario.startsWith("compaction-") const emptySummary = scenario.startsWith("compaction-empty-summary") + const streamedSummary = scenario === "compaction-streamed-error" || scenario === "compaction-empty-summary-streamed" const userDuringEcho = scenario.startsWith("user-during-") const queuedUser = scenario === "queued-user" || scenario === "queued-user-reversed-ids" const userAfterCommit = scenario === "new-user-after-commit-reversed-ids" @@ -426,7 +483,9 @@ for (const scenario of [ "echo-overflow-enabled": "a context overflow during echo publishes the committed output without fatal events", "echo-reactive-compaction": "an echo triggering reactive compaction publishes only the committed text without a continuation", "compaction-error": "a 503 compaction summariser failure after a commit publishes the committed output without fatal events", + "compaction-streamed-error": "a streamed partial compaction summary followed by a 503 delivers only the committed answer to ACP and run", "compaction-empty-summary": "two empty compaction summaries after a commit publish the committed output without fatal events", + "compaction-empty-summary-streamed": "two empty compaction summaries after streamed partial text deliver only the committed answer to ACP and run", "compaction-empty-summary-without-commit": "two empty compaction summaries without a commit retain the existing UnknownError", "compaction-forbidden": "a 403 compaction summariser failure after a commit remains fatal", "compaction-guardrail-denied": "a 503 orchestration guardrail denial during compaction after a commit remains fatal", @@ -582,6 +641,8 @@ for (const scenario of [ .tool("answer_commit", scenario === "failed" ? {} : args) .usage({ input: compacting ? 95000 : 0, output: 1 }) .item() + : streamedSummary + ? reply().text("Summary so far: ").stop().item() : emptySummary || scenario === "echo-no-text" ? (emptySummary ? reply().text("") : reply()).stop().item() : structured && (!priorCommit || requests.length > 2) @@ -641,6 +702,41 @@ for (const scenario of [ { preconnect: () => undefined }, ), }).chatModel("test-model") + const doStream = language.doStream.bind(language) + const streamSpy = scenario === "compaction-streamed-error" + ? spyOn(language, "doStream").mockImplementation(async (options) => { + const response = await doStream(options) + if (requests.length !== 2) return response + let failed = false + return { ...response, stream: response.stream.pipeThrough(new TransformStream({ + transform(value, controller) { + if (failed) return + controller.enqueue(value) + if (value.type !== "text-delta") return + failed = true + controller.enqueue({ type: "error", error: new APICallError({ + message: "Compaction summariser failed after partial text", + url: "https://unused.invalid/v1/chat/completions", + requestBodyValues: {}, + statusCode: 503, + isRetryable: true, + }) }) + }, + })) } + }) + : undefined + // Completion hooks can discard streamed text, leaving an empty summary + // on both attempts even though clients already saw its deltas. + const trigger = Plugin.trigger + const completedTextIDs: PartID[] = [] + const textHookSpy = spyOn(Plugin, "trigger").mockImplementation(async (...input) => { + if (input[0] === "experimental.text.complete") + completedTextIDs.push(PartID.make((input[1] as { partID: string }).partID)) + const result = await trigger(...input) + if (scenario === "compaction-empty-summary-streamed" && input[0] === "experimental.text.complete" && requests.length > 1) + return { ...result, text: "" } + return result + }) const languageSpy = spyOn(Provider, "getLanguage").mockResolvedValue(language) const updatePart = LegacySession.updatePart const commitSpy = userAfterCommit @@ -686,7 +782,9 @@ for (const scenario of [ const compactionTextIDs: MessageID[] = [] const textUpdates: MessageV2.TextPart[] = [] const textPartIDs = new Set() - const textDeltas: { messageID: MessageID; text: string }[] = [] + const textDeltas: { messageID: MessageID; partID: PartID; text: string }[] = [] + const removedTextIDs: PartID[] = [] + const clientEvents: ClientEvent[] = [] const textEvents: { messageID: MessageID; type: string; text: string }[] = [] const sessionErrors: unknown[] = [] const assistantUpdates: MessageV2.Assistant[] = [] @@ -706,6 +804,7 @@ for (const scenario of [ if (event.type === MessageV2.Event.Updated.type) { const message = event.properties.info if (message.sessionID === chat.id && message.role === "assistant") { + clientEvents.push(structuredClone(event) as unknown as ClientEvent) assistantUpdates.push(structuredClone(message)) accounting.onAssistantMessage(message) } @@ -715,6 +814,7 @@ for (const scenario of [ if (part.sessionID !== chat.id) return if (part.type === "step-start") output.stepStart(accounting.isCompactionStep(part.messageID)) if (part.type !== "text") return + clientEvents.push(structuredClone(event) as unknown as ClientEvent) // Snapshot the published value: later mutations must not rewrite what a client observed. textUpdates.push(structuredClone(part)) textEvents.push({ messageID: part.messageID, type: part.time?.end ? "completion" : "start", text: part.text }) @@ -723,7 +823,7 @@ for (const scenario of [ if (part.time?.end) { const compaction = accounting.isCompactionStep(part.messageID) if (compaction) compactionTextIDs.push(part.messageID) - output.text(part.text.trim(), { synthetic: part.synthetic === true, compaction }) + output.text(part.text.trim(), { partID: part.id, synthetic: part.synthetic === true, compaction }) } } if ( @@ -733,9 +833,15 @@ for (const scenario of [ textPartIDs.has(event.properties.partID) ) { // ACP forwards deltas for known text parts and ignores later text updates. - textDeltas.push({ messageID: event.properties.messageID, text: event.properties.delta }) + clientEvents.push(structuredClone(event) as unknown as ClientEvent) + textDeltas.push({ messageID: event.properties.messageID, partID: event.properties.partID, text: event.properties.delta }) textEvents.push({ messageID: event.properties.messageID, type: "delta", text: event.properties.delta }) } + if (event.type === MessageV2.Event.PartRemoved.type && event.properties.sessionID === chat.id) { + clientEvents.push(structuredClone(event) as unknown as ClientEvent) + removedTextIDs.push(event.properties.partID) + output.remove(event.properties.partID) + } } GlobalBus.on("event", collect) try { @@ -858,6 +964,32 @@ for (const scenario of [ return } expect(result.info).not.toHaveProperty("error") + if (streamedSummary) { + const chunks = yield* acpText(clientEvents, chat.id) + expect(chunks).toEqual([renderedAnswer]) + expect(output.content()).toBe(renderedAnswer) + const partials = textDeltas.filter((part) => part.text.includes("Summary so far")) + expect(partials).toHaveLength(emptySummary ? 2 : 1) + const parts = result.parts.filter((part) => part.type === "text") + expect(parts.map((part) => part.text)).toEqual([renderedAnswer]) + for (const partial of partials) { + expect(removedTextIDs).toContain(partial.partID) + expect(parts[0].id).not.toBe(partial.partID) + } + expect(result.info).toHaveProperty("finish", "stop") + expect(result.info).not.toHaveProperty("summary", true) + expect(result.info).toHaveProperty("metadata.answer_commit_echo_error.data.message", emptySummary + ? "Compaction summarizer produced no summary text after retry" + : "Compaction summariser failed after partial text") + if (!emptySummary) expect(result.info).toHaveProperty("metadata.answer_commit_echo_error.data.statusCode", 503) + expect(sessionErrors).toEqual([]) + expect(accounting.fatal).toBe(false) + expect(requests).toHaveLength(emptySummary ? 3 : 2) + expect(JSON.stringify(requests[1].messages)).toContain("Provide a detailed prompt for continuing") + const persisted = (yield* sessions.messages({ sessionID: chat.id })).find((message) => message.info.id === result.info.id) + expect(persisted?.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual([renderedAnswer]) + return + } if (scenario === "echo-reactive-compaction") { expect(result.info).toHaveProperty("finish", "stop") expect(result.info).toHaveProperty("metadata.answer_commit_echo_error.result", "compact") @@ -1014,6 +1146,13 @@ for (const scenario of [ .join(""), ).toBe(expectedText) const finalUpdates = textUpdates.filter((part) => part.messageID === result.info.id) + if (scenario === "text" || scenario === "echo-mismatch" || scenario === "echo-write-order") { + // The ordinary echo reuses its buffered part, with one delivery. + expect(completedTextIDs).toEqual([finalUpdates[0].id]) + expect(finalUpdates.map((part) => part.id)).toEqual([completedTextIDs[0], completedTextIDs[0]]) + expect(removedTextIDs).toEqual([]) + expect(yield* acpText(clientEvents, chat.id)).toEqual([expectedText]) + } expect(finalUpdates.filter((part) => part.time?.end).map((part) => part.text)).toEqual([expectedText]) expect(output.content()).toBe(expectedText) expect( @@ -1081,6 +1220,8 @@ for (const scenario of [ snapshotSpy.mockRestore() historySpy.mockRestore() languageSpy.mockRestore() + streamSpy?.mockRestore() + textHookSpy?.mockRestore() } }), ), From 39e618a0575436301da940c2e27fda6f8a83879d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 13 Oct 2026 00:07:13 -0700 Subject: [PATCH 28/39] fix(analyst): structured policy codes win over overflow-looking text; never finalize onto a completed compaction summary - `message-v2.ts`: an explicit policy code (`guardrail_intervened` incl. nested orchestration codes, `content_filter`, `content_policy*`, `policy_violation`, `safety_*`, `moderation_*`) keeps the error fatal even when the message text looks like a context overflow; explicit overflow codes (`context_length_exceeded`, `content_length_exceeded`) and HTTP 413 stay overflows; policy metadata survives response truncation - `prompt.ts`: when the message that would carry a recovered committed answer is a finished, error-free, non-empty compaction summary (a user request was queued during compaction), the summary stays intact as the compaction boundary and the committed text is published once on a new assistant message with the committed request's parent, agent, mode, model, variant and path; failed/empty summariser recovery still converts its own summary message - tests: 503 guardrail with overflow text during echo stays fatal without publishing or compacting; queued user after successful compaction keeps the summary and gets tools after the new committed-answer message; 413 `content_length_exceeded` still compacts and resumes; 21 `fromError` cases for code vs text precedence Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/message-v2.ts | 27 ++-- packages/opencode/src/session/prompt.ts | 46 ++++++- .../opencode/test/agent/answer-commit.test.ts | 126 +++++++++++++++++- .../opencode/test/session/message-v2.test.ts | 71 ++++++++++ 4 files changed, 246 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 9763af94a0..435d1031db 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -544,15 +544,15 @@ export namespace MessageV2 { function isEchoPolicyCode(code: string | undefined): boolean { if (!code) return false - return echoPolicyCodes.has(code.toLowerCase()) || /^(safety|moderation)_.+$/i.test(code) + return echoPolicyCodes.has(code.toLowerCase()) || /^(content_policy.*|(safety|moderation)_.+)$/i.test(code) } - function echoPolicyCode(responseBody: string | undefined): string | undefined { + function responseErrorCode(responseBody: string | undefined, matches: (code: string) => boolean): string | undefined { if (!responseBody) return undefined const find = (value: unknown): string | undefined => { if (!value || typeof value !== "object") return undefined for (const [key, child] of Object.entries(value)) { - if (key === "code" && typeof child === "string" && isEchoPolicyCode(child)) return child + if (key === "code" && typeof child === "string" && matches(child)) return child const nested = find(child) if (nested) return nested } @@ -564,6 +564,10 @@ export namespace MessageV2 { } } + function echoPolicyCode(responseBody: string | undefined): string | undefined { + return responseErrorCode(responseBody, isEchoPolicyCode) + } + export function isEchoTransportError(error: Assistant["error"]): boolean { if (error?.name !== "APIError") return false const { statusCode, responseBody, metadata, isRetryable } = error.data @@ -1271,23 +1275,25 @@ export namespace MessageV2 { providerID: ctx.providerID, error: e, }) - // altimate_change start — retain policy decisions before response truncation without overriding context overflow + // altimate_change start — structured policy decisions override overflow text, while explicit overflow codes win + const overflowCode = responseErrorCode(e.responseBody, (code) => + ["context_length_exceeded", "content_length_exceeded"].includes(code.toLowerCase()), + ) const policyCode = echoPolicyCode(e.responseBody) - if (parsed.type === "api_error" && policyCode) { + if (policyCode && e.statusCode !== 413 && !overflowCode) { return new MessageV2.APIError( { message: parsed.message, statusCode: e.statusCode, - isRetryable: parsed.isRetryable, - responseHeaders: parsed.responseHeaders, + isRetryable: parsed.type === "api_error" ? parsed.isRetryable : e.isRetryable, + responseHeaders: parsed.type === "api_error" ? parsed.responseHeaders : e.responseHeaders, responseBody: parsed.responseBody, - metadata: { ...parsed.metadata, code: policyCode }, + metadata: { ...(parsed.type === "api_error" ? parsed.metadata : undefined), code: policyCode }, }, { cause: e }, ).toObject() } - // altimate_change end - if (parsed.type === "context_overflow") { + if (parsed.type === "context_overflow" || overflowCode) { return new MessageV2.ContextOverflowError( { message: parsed.message, @@ -1296,6 +1302,7 @@ export namespace MessageV2 { { cause: e }, ).toObject() } + // altimate_change end return new MessageV2.APIError( { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a1db049a35..83c7392550 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -827,8 +827,12 @@ export namespace SessionPrompt { }) using _unsubToolsChanged = defer(unsubscribeToolsChanged) // altimate_change end - // altimate_change start - retain the exact committed text through automatic compaction - let committedAnswer: { userID: MessageID; output: string } | undefined + // altimate_change start — retain the exact committed text through automatic compaction + let committedAnswer: { + userID: MessageID + output: string + assistant: Pick + } | undefined const compactionContinuations = new Set() // The legacy message table has no explicit sequence column. Its SQLite rowid // records insertion order and survives updateMessage's data-only upserts. @@ -870,6 +874,14 @@ export namespace SessionPrompt { commit.state.status === "completed" ? { userID: user.id, + assistant: { + agent: assistant.agent, + mode: assistant.mode, + modelID: assistant.modelID, + providerID: assistant.providerID, + variant: assistant.variant, + path: assistant.path, + }, // Completed-tool metadata retains the full answer through the per-result output cap. output: typeof commit.state.metadata.committed_text === "string" @@ -915,7 +927,31 @@ export namespace SessionPrompt { return false // Once the echo has started, this answer still belongs to the earlier request. // Publish it before the loop continues to any user request queued during generation. - const storedText = MessageV2.parts(message.id).filter((part) => part.type === "text") + const recoveryCause = error ?? message.metadata?.answer_commit_echo_error + const cause = + recoveryCause ?? + (message.finish !== "stop" ? { finish: message.finish ?? "unknown" } : undefined) + let storedText = MessageV2.parts(message.id).filter((part) => part.type === "text") + if ( + message.summary && message.finish && !message.error && + !message.metadata?.answer_commit_echo_error && + storedText.some((part) => part.text.trim().length > 0) + ) { + // A successful summary remains the compaction boundary. Deliver the + // committed request's answer separately, retaining its original identity. + message = await Session.updateMessage({ + ...committedAnswer.assistant, + id: MessageID.ascending(), + parentID: committedAnswer.userID, + sessionID, + role: "assistant", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now() }, + }) as MessageV2.Assistant + storedText = [] + deferredText = [] + } // Only buffered parts are safe to reuse: stored parts may already have // streamed deltas, and a blank update does not reset client text. const first = deferredText[0] @@ -930,10 +966,6 @@ export namespace SessionPrompt { for (const old of storedText) { if (old.id !== part.id) await Session.removePart({ sessionID, messageID: message.id, partID: old.id }) } - const recoveryCause = error ?? message.metadata?.answer_commit_echo_error - const cause = - recoveryCause ?? - (message.finish !== "stop" ? { finish: message.finish ?? "unknown" } : undefined) if (cause) message.metadata = { ...message.metadata, answer_commit_echo_error: cause } delete message.error // A failed summariser becomes the exiting answer, so clients must no longer hide it as compaction machinery. diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 7148190d83..1e16ea8369 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -17,6 +17,7 @@ import { Provider } from "../../src/provider/provider" import { Session } from "../../src/session/session" import { Session as LegacySession } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" +import { SessionCompaction } from "../../src/session/compaction" import { MessageV2 } from "../../src/session/message-v2" import { SessionSummary } from "../../src/session/summary" // altimate_change start — error provenance fixtures for committed-answer echoes @@ -422,11 +423,14 @@ for (const scenario of [ "echo-socket-reset", "echo-policy-denied", "echo-guardrail-denied", + "echo-guardrail-overflow-text", "echo-forbidden", "echo-snapshot-error", "echo-overflow-enabled", "echo-reactive-compaction", "compaction-error", + "compaction-queued-user", + "overflow-code-compaction", "compaction-streamed-error", "compaction-empty-summary", "compaction-empty-summary-streamed", @@ -467,6 +471,9 @@ for (const scenario of [ const userDuringEcho = scenario.startsWith("user-during-") const queuedUser = scenario === "queued-user" || scenario === "queued-user-reversed-ids" const userAfterCommit = scenario === "new-user-after-commit-reversed-ids" + const userAfterCompaction = scenario === "compaction-queued-user" + const overflowCodeCompaction = scenario === "overflow-code-compaction" + const summaryText = "Retained summary: counted 42 records using records.id." const reversedIDs = scenario.includes("reversed-ids") const name = { text: "text requests offer answer_commit and immediately echo a successful commit without a step limit", @@ -478,11 +485,14 @@ for (const scenario of [ "echo-socket-reset": "a socket reset during echo publishes the committed output without fatal events", "echo-policy-denied": "a 400 structured policy denial during echo emits fatal message and session errors", "echo-guardrail-denied": "a 503 orchestration guardrail denial during echo remains fatal", + "echo-guardrail-overflow-text": "a 503 guardrail denial with overflow text during echo stays fatal without publishing or compacting", "echo-forbidden": "a 403 echo failure retains its fatal error without publishing the committed output", "echo-snapshot-error": "a local snapshot failure before the echo request retains its fatal UnknownError", "echo-overflow-enabled": "a context overflow during echo publishes the committed output without fatal events", "echo-reactive-compaction": "an echo triggering reactive compaction publishes only the committed text without a continuation", "compaction-error": "a 503 compaction summariser failure after a commit publishes the committed output without fatal events", + "compaction-queued-user": "a queued user after successful compaction preserves the summary and receives tools after a new committed-answer message", + "overflow-code-compaction": "a 413 content_length_exceeded response triggers compaction and resumes with tools", "compaction-streamed-error": "a streamed partial compaction summary followed by a 503 delivers only the committed answer to ACP and run", "compaction-empty-summary": "two empty compaction summaries after a commit publish the committed output without fatal events", "compaction-empty-summary-streamed": "two empty compaction summaries after streamed partial text deliver only the committed answer to ACP and run", @@ -588,19 +598,27 @@ for (const scenario of [ async (_url: string | URL | Request, init?: RequestInit) => { if (typeof init?.body !== "string") throw new Error("expected a JSON request body") requests.push(JSON.parse(init.body)) - if (requests.length > (priorCommit ? 4 : userDuringEcho || emptySummary ? 3 : 2)) + if (requests.length > (priorCommit ? 4 : userDuringEcho || emptySummary || userAfterCompaction || overflowCodeCompaction ? 3 : 2)) throw new Error("unexpected extra generation") const step = priorCommit && requests.length > 2 ? requests.length - 2 : requests.length + if (step === 1 && overflowCodeCompaction) { + return new Response(JSON.stringify({ error: { code: "content_length_exceeded", message: "Request entity too large" } }), { + status: 413, + headers: { "Content-Type": "application/json" }, + }) + } if ( step === 2 && [ - "echo-transport-error", "echo-policy-denied", "echo-guardrail-denied", "echo-forbidden", "echo-http-content-filter", + "echo-transport-error", "echo-policy-denied", "echo-guardrail-denied", "echo-guardrail-overflow-text", "echo-forbidden", "echo-http-content-filter", "compaction-error", "compaction-forbidden", "compaction-guardrail-denied", "compaction-content-filter", "echo-overflow-disabled", "echo-overflow-enabled", ].includes(scenario) ) { const error = scenario === "echo-policy-denied" ? { code: "policy_violation", message: "Request denied" } + : scenario === "echo-guardrail-overflow-text" + ? { code: "guardrail_intervened", message: "maximum context length exceeded" } : scenario === "echo-http-content-filter" || scenario === "compaction-content-filter" ? { code: "content_filter", message: "Echo request blocked by content policy" } : scenario === "echo-overflow-disabled" || scenario === "echo-overflow-enabled" @@ -610,7 +628,7 @@ for (const scenario of [ return new Response(JSON.stringify(guardrail ? { error, orchestrationError: { code: "guardrail_intervened" } } : { error }), { - status: scenario === "echo-transport-error" || scenario === "compaction-error" || guardrail + status: scenario === "echo-transport-error" || scenario === "compaction-error" || guardrail || scenario === "echo-guardrail-overflow-text" ? 503 : scenario === "echo-forbidden" || scenario === "compaction-forbidden" ? 403 : 400, headers: { "Content-Type": "application/json" }, @@ -641,6 +659,8 @@ for (const scenario of [ .tool("answer_commit", scenario === "failed" ? {} : args) .usage({ input: compacting ? 95000 : 0, output: 1 }) .item() + : step === 2 && (userAfterCompaction || overflowCodeCompaction) + ? reply().text(summaryText).stop().item() : streamedSummary ? reply().text("Summary so far: ").stop().item() : emptySummary || scenario === "echo-no-text" @@ -739,6 +759,20 @@ for (const scenario of [ }) const languageSpy = spyOn(Provider, "getLanguage").mockResolvedValue(language) const updatePart = LegacySession.updatePart + let completedSummary: MessageV2.WithParts | undefined + const processCompaction = SessionCompaction.process + const compactionSpy = userAfterCompaction + ? spyOn(SessionCompaction, "process").mockImplementation(Object.assign(async (...input: Parameters) => { + const result = await processCompaction(...input) + expect(result).toBe("continue") + completedSummary = structuredClone([...MessageV2.stream(chat.id)].find( + (message) => message.info.role === "assistant" && message.info.summary && message.info.finish && !message.info.error, + )) + expect(completedSummary).toBeDefined() + await insertUser() + return result + }, processCompaction)) + : undefined const commitSpy = userAfterCommit ? spyOn(LegacySession, "updatePart").mockImplementation(Object.assign(async (part: MessageV2.Part) => { const result = await updatePart(part) @@ -753,7 +787,7 @@ for (const scenario of [ if (scenario === "echo-snapshot-error" && echoPrepared) throw new Error("Snapshot tracking failed") return track() }) - const retrySpy = scenario === "compaction-error" || scenario === "echo-guardrail-denied" || scenario === "compaction-guardrail-denied" + const retrySpy = scenario === "compaction-error" || scenario === "echo-guardrail-denied" || scenario === "echo-guardrail-overflow-text" || scenario === "compaction-guardrail-denied" ? spyOn(SessionRetry, "retryable").mockReturnValue(undefined) : undefined const finishedEchoText: string[][] = [] @@ -787,6 +821,7 @@ for (const scenario of [ const clientEvents: ClientEvent[] = [] const textEvents: { messageID: MessageID; type: string; text: string }[] = [] const sessionErrors: unknown[] = [] + let compactions = 0 const assistantUpdates: MessageV2.Assistant[] = [] const requestStarts: string[] = [] const collect = ({ payload: event }: GlobalEvent) => { @@ -801,6 +836,10 @@ for (const scenario of [ sessionErrors.push(event.properties.error) accounting.onSessionError(event.properties.error?.name) } + if (event.type === "session.compacted" && event.properties.sessionID === chat.id) { + compactions++ + accounting.onCompactionRecovered() + } if (event.type === MessageV2.Event.Updated.type) { const message = event.properties.info if (message.sessionID === chat.id && message.role === "assistant") { @@ -927,6 +966,7 @@ for (const scenario of [ scenario === "echo-snapshot-error" || scenario === "echo-policy-denied" || scenario === "echo-guardrail-denied" || + scenario === "echo-guardrail-overflow-text" || scenario === "echo-forbidden" || scenario === "echo-aborted" || scenario === "echo-content-filter" || @@ -943,7 +983,7 @@ for (const scenario of [ : scenario === "echo-aborted" || scenario === "compaction-aborted" ? "MessageAbortedError" : scenario === "echo-http-content-filter" || scenario === "compaction-content-filter" || - scenario === "echo-policy-denied" || scenario === "echo-guardrail-denied" || scenario === "echo-forbidden" || + scenario === "echo-policy-denied" || scenario === "echo-guardrail-denied" || scenario === "echo-guardrail-overflow-text" || scenario === "echo-forbidden" || scenario === "compaction-forbidden" || scenario === "compaction-guardrail-denied" ? "APIError" : "ContentFilterError", @@ -954,6 +994,13 @@ for (const scenario of [ expect(sessionErrors).toHaveLength(1) expect(accounting.fatal).toBe(true) expect(assistantUpdates.some((message) => message.id === result.info.id && message.error)).toBe(true) + if (scenario === "echo-guardrail-overflow-text") { + expect(compactions).toBe(0) + const history = yield* sessions.messages({ sessionID: chat.id }) + expect(history.flatMap((message) => message.parts).filter((part) => part.type === "compaction")).toEqual([]) + expect(textUpdates.filter((part) => part.time?.end)).toEqual([]) + expect(yield* acpText(clientEvents, chat.id)).toEqual([]) + } if (scenario !== "echo-content-filter") { expect(result.parts.filter((part) => part.type === "text")).toEqual([]) expect(output.content()).toBe("(no text output — tool-only response)") @@ -964,6 +1011,67 @@ for (const scenario of [ return } expect(result.info).not.toHaveProperty("error") + if (userAfterCompaction || overflowCodeCompaction) { + expect(compactions).toBe(1) + expect(requests).toHaveLength(3) + expect(JSON.stringify(requests[1].messages)).toContain("Provide a detailed prompt for continuing") + expect(requests[1].tools ?? []).toEqual([]) + expect((requests[2].tools ?? []).map((tool) => tool.function.name)).toContain("answer_commit") + expect(JSON.stringify(requests[2].messages)).not.toContain(echoInstruction) + expect(JSON.stringify(requests[2].messages)).toContain(summaryText) + const history = [...MessageV2.stream(chat.id)].reverse() + const summary = history.find((message) => message.info.role === "assistant" && message.info.summary) + if (!summary) throw new Error("missing successful compaction summary") + expect(summary.info).toHaveProperty("finish", "stop") + expect(summary.info).not.toHaveProperty("error") + expect(summary.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual([summaryText]) + const filtered = MessageV2.filterCompacted(MessageV2.stream(chat.id)) + expect(filtered[0].parts.some((part) => part.type === "compaction")).toBe(true) + expect(filtered[1].info.id).toBe(summary.info.id) + expect(accounting.fatal).toBe(false) + expect(result.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual(["3"]) + if (overflowCodeCompaction) { + expect(sessionErrors).toEqual([expect.objectContaining({ name: "ContextOverflowError" })]) + expect(history.flatMap((message) => message.parts)).toContainEqual(expect.objectContaining({ + type: "compaction", auto: true, overflow: true, + })) + return + } + expect(sessionErrors).toEqual([]) + if (!completedSummary) throw new Error("missing saved compaction summary") + expect(summary).toEqual(completedSummary) + const commit = history.find((message) => message.parts.some((part) => part.type === "tool" && part.tool === "answer_commit")) + if (commit?.info.role !== "assistant") throw new Error("missing committed request") + const answers = history.filter((message) => message.info.role === "assistant" && + message.parts.some((part) => part.type === "text" && part.text === renderedAnswer)) + expect(answers).toHaveLength(1) + const answer = answers[0] + expect(answer.info.id).not.toBe(summary.info.id) + expect(answer.info.id).not.toBe(commit.info.id) + expect(answer.info).toMatchObject({ + parentID: commit.info.parentID, + agent: commit.info.agent, + mode: commit.info.mode, + modelID: commit.info.modelID, + providerID: commit.info.providerID, + finish: "stop", + }) + expect(answer.info).not.toHaveProperty("summary") + expect(answer.info).not.toHaveProperty("error") + expect(answer.info).not.toHaveProperty("metadata.answer_commit_echo_error") + expect(textEvents.filter((event) => event.messageID === answer.info.id)).toEqual([ + { messageID: answer.info.id, type: "start", text: "" }, + { messageID: answer.info.id, type: "delta", text: renderedAnswer }, + { messageID: answer.info.id, type: "completion", text: renderedAnswer }, + ]) + expect(yield* acpText(clientEvents, chat.id)).toEqual([renderedAnswer, "3"]) + expect(compactionTextIDs).not.toContain(answer.info.id) + expect(removedTextIDs).toEqual([]) + expect(queuedUserID).toBeDefined() + expect(result.info).toHaveProperty("parentID", queuedUserID) + expect(JSON.stringify(requests[2].messages)).toContain("A new question: how many sources?") + return + } if (streamedSummary) { const chunks = yield* acpText(clientEvents, chat.id) expect(chunks).toEqual([renderedAnswer]) @@ -1215,6 +1323,7 @@ for (const scenario of [ } finally { GlobalBus.off("event", collect) commitSpy?.mockRestore() + compactionSpy?.mockRestore() finishSpy?.mockRestore() retrySpy?.mockRestore() snapshotSpy.mockRestore() @@ -1292,14 +1401,17 @@ describe("committed-answer echo error classification", () => { expect(error.name).toBe("APIError") expect(MessageV2.isEchoTransportError(error)).toBe(false) }) - test("a structured policy code never overrides an existing context overflow classification", () => { + test("a structured policy code never overrides an explicit context overflow code", () => { const error = MessageV2.fromError(new APICallError({ message: "maximum context length exceeded", url: "https://example.com/v1/chat/completions", requestBodyValues: {}, statusCode: 400, isRetryable: false, - responseBody: JSON.stringify({ error: { code: "policy_violation", message: "maximum context length exceeded" } }), + responseBody: JSON.stringify({ + error: { code: "context_length_exceeded", message: "maximum context length exceeded" }, + orchestrationError: { code: "policy_violation" }, + }), }), { providerID: promptModel.providerID }) expect(error.name).toBe("ContextOverflowError") expect(MessageV2.isEchoTransportError(error)).toBe(false) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 5e4372b1ad..af207a80f4 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1531,6 +1531,77 @@ describe("session.message-v2.fromError", () => { expect(SessionV1.ContextOverflowError.isInstance(result)).toBe(true) }) + for (const statusCode of [400, 503]) { + for (const code of [ + "guardrail_intervened", "content_filter", "content_policy", "content_policy_violation", + "content_policy_blocked", "policy_violation", "safety_rejection", "moderation_blocked", + ]) { + test(`HTTP ${statusCode} structured ${code} overrides overflow text`, () => { + const result = MessageV2.fromError(new APICallError({ + message: "maximum context length exceeded", + url: "https://example.com", + requestBodyValues: {}, + statusCode, + isRetryable: statusCode === 503, + responseBody: JSON.stringify({ error: { code, message: "maximum context length exceeded" } }), + }), { providerID }) + + expect(result.name).toBe("APIError") + expect((result.data as MessageV2.APIError["data"]).metadata?.code).toBe(code) + expect(MessageV2.isEchoTransportError(result)).toBe(false) + }) + } + + test(`HTTP ${statusCode} nested orchestration guardrail overrides overflow text`, () => { + const result = MessageV2.fromError(new APICallError({ + message: "maximum context length exceeded", + url: "https://example.com", + requestBodyValues: {}, + statusCode, + isRetryable: statusCode === 503, + responseBody: JSON.stringify({ + error: { message: "maximum context length exceeded" }, + details: { orchestrationError: { code: "guardrail_intervened" } }, + }), + }), { providerID }) + + expect(result.name).toBe("APIError") + expect((result.data as MessageV2.APIError["data"]).metadata?.code).toBe("guardrail_intervened") + expect(MessageV2.isEchoTransportError(result)).toBe(false) + }) + } + + for (const code of ["context_length_exceeded", "content_length_exceeded"]) { + test(`explicit ${code} remains overflow despite a nested policy code`, () => { + const result = MessageV2.fromError(new APICallError({ + message: "Request failed", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 400, + isRetryable: false, + responseBody: JSON.stringify({ + error: { code }, + orchestrationError: { code: "guardrail_intervened" }, + }), + }), { providerID }) + + expect(result.name).toBe("ContextOverflowError") + }) + } + + test("HTTP 413 remains overflow despite a structured policy code", () => { + const result = MessageV2.fromError(new APICallError({ + message: "Request failed", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 413, + isRetryable: false, + responseBody: JSON.stringify({ error: { code: "policy_violation" } }), + }), { providerID }) + + expect(result.name).toBe("ContextOverflowError") + }) + test("does not classify 429 no body as context overflow", () => { const result = MessageV2.fromError( new APICallError({ From 317dcfb27830b4a6f2cfffaeb1d6918a67a62655 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 13 Oct 2026 00:35:33 -0700 Subject: [PATCH 29/39] fix(analyst): persist a recovered committed answer before any request queued during compaction The round-19 path that publishes a recovered committed answer on a new assistant message timestamped it with the current time, which sorted it after a user request queued during compaction, so later prompts replayed it as the reply to that newer request. The new message now takes the earliest newer user request's persisted `time_created` minus one millisecond (compaction markers and synthetic continuations excluded), completion never precedes creation, and the loop reloads history after the recovery so the queued request sees the answer in its original place. Test: persisted order is summary, recovered answer, queued user; the queued request's model history shows the committed text before its own turn. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/prompt.ts | 20 ++++++++++-- .../opencode/test/agent/answer-commit.test.ts | 31 +++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 83c7392550..5f51f17b61 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -939,6 +939,19 @@ export namespace SessionPrompt { ) { // A successful summary remains the compaction boundary. Deliver the // committed request's answer separately, retaining its original identity. + const order = readMessageOrder() + let created = Date.now() + for (const msg of orderedMessageStream(order)) { + if (compareMessageOrder(order, msg.info.id, committedAnswer.userID) <= 0) break + if ( + msg.info.role === "user" && + !msg.parts.some((part) => part.type === "compaction") && + !compactionContinuations.has(msg.info.id) + ) { + // Walk newest to oldest so the answer precedes the earliest queued request. + created = order.get(msg.info.id)!.created - 1 + } + } message = await Session.updateMessage({ ...committedAnswer.assistant, id: MessageID.ascending(), @@ -947,7 +960,7 @@ export namespace SessionPrompt { role: "assistant", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: Date.now() }, + time: { created }, }) as MessageV2.Assistant storedText = [] deferredText = [] @@ -978,7 +991,7 @@ export namespace SessionPrompt { // Persist the authoritative text before making this message terminal on resume. await publishText([part]) message.finish = message.finish === "other" && !recoveryCause ? "other" : "stop" - message.time.completed = Date.now() + message.time.completed = Math.max(message.time.created, Date.now()) await Session.updateMessage(message) committedAnswer = undefined return true @@ -1050,7 +1063,8 @@ export namespace SessionPrompt { ) { // A request queued after the commit still receives its own response; // publish the completed answer for the earlier request first. - if (lastAssistant) await finishCommittedAnswer(lastAssistant) + // Reload persisted history so the queued request includes the recovered answer. + if (lastAssistant && (await finishCommittedAnswer(lastAssistant))) continue committedAnswer = undefined } if (commitRequest?.info.role === "user" && lastAssistant) { diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 1e16ea8369..76d6de239d 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -23,6 +23,8 @@ import { SessionSummary } from "../../src/session/summary" // altimate_change start — error provenance fixtures for committed-answer echoes import { APICallError } from "ai" import { SessionRetry } from "../../src/session/retry" +import { Database, eq, sql } from "../../src/storage/db" +import { MessageTable } from "@opencode-ai/core/session/sql" import { Snapshot } from "../../src/snapshot" import * as RunAccounting from "../../src/cli/cmd/run-accounting" import { ACPEvent } from "../../src/acp/event" @@ -550,7 +552,7 @@ for (const scenario of [ const renderedAnswer = renderAnswer(args) const originalUserID = reversedIDs ? MessageID.make(`msg_zzzz_original_${chat.id}`) : undefined let queuedUserID: MessageID | undefined - const insertUser = async () => { + const insertUser = async (created = Date.now()) => { if (reversedIDs) { const original = MessageV2.get({ sessionID: chat.id, messageID: originalUserID! }) const createdSpy = scenario === "user-during-echo-reversed-ids-same-time" @@ -580,7 +582,7 @@ for (const scenario of [ role: "user", agent: "analyst", model: promptModel, - time: { created: Date.now() }, + time: { created }, }) await LegacySession.updatePart({ id: PartID.ascending(), @@ -769,7 +771,8 @@ for (const scenario of [ (message) => message.info.role === "assistant" && message.info.summary && message.info.finish && !message.info.error, )) expect(completedSummary).toBeDefined() - await insertUser() + // Leave room to recover the answer after the summary and before this request. + await insertUser(Math.max(Date.now(), completedSummary!.info.time.created + 1)) return result }, processCompaction)) : undefined @@ -1046,6 +1049,7 @@ for (const scenario of [ message.parts.some((part) => part.type === "text" && part.text === renderedAnswer)) expect(answers).toHaveLength(1) const answer = answers[0] + if (answer.info.role !== "assistant") throw new Error("missing recovered committed answer") expect(answer.info.id).not.toBe(summary.info.id) expect(answer.info.id).not.toBe(commit.info.id) expect(answer.info).toMatchObject({ @@ -1059,6 +1063,27 @@ for (const scenario of [ expect(answer.info).not.toHaveProperty("summary") expect(answer.info).not.toHaveProperty("error") expect(answer.info).not.toHaveProperty("metadata.answer_commit_echo_error") + const ordered = Database.use((db) => db + .select({ id: MessageTable.id, created: MessageTable.time_created }) + .from(MessageTable) + .where(eq(MessageTable.session_id, chat.id)) + .orderBy(MessageTable.time_created, sql`rowid`) + .all()) + const summaryIndex = ordered.findIndex((message) => message.id === summary.info.id) + const answerIndex = ordered.findIndex((message) => message.id === answer.info.id) + const queuedIndex = ordered.findIndex((message) => message.id === queuedUserID) + expect(summaryIndex).toBeGreaterThanOrEqual(0) + expect(answerIndex).toBeGreaterThan(summaryIndex) + expect(queuedIndex).toBeGreaterThan(answerIndex) + expect(ordered[answerIndex].created).toBe(ordered[queuedIndex].created - 1) + expect(answer.info.time.completed).toBeGreaterThanOrEqual(answer.info.time.created) + const modelHistory = requests[2].messages as { role: string; content: unknown }[] + const answerTurn = modelHistory.findIndex((message) => message.role === "assistant" && message.content === renderedAnswer) + const queuedTurn = modelHistory.findIndex((message) => message.role === "user" && + JSON.stringify(message.content).includes("A new question: how many sources?")) + expect(answerTurn).toBeGreaterThanOrEqual(0) + expect(queuedTurn).toBeGreaterThan(answerTurn) + expect(modelHistory.findLastIndex((message) => message.role === "user")).toBe(queuedTurn) expect(textEvents.filter((event) => event.messageID === answer.info.id)).toEqual([ { messageID: answer.info.id, type: "start", text: "" }, { messageID: answer.info.id, type: "delta", text: renderedAnswer }, From 669946ca8aeeba6dd5e0a6785612ac903b51f4d2 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 13 Oct 2026 00:39:14 -0700 Subject: [PATCH 30/39] fix(analyst): only the provider's own error code decides a context overflow The overflow check searched every nested `code` in the response body, so a diagnostic field such as `details.request.code: context_length_exceeded` could mask a top-level `policy_violation` and let the echo guard publish a committed answer against a provider denial. Overflow precedence now reads only `error.code` or the root `code` (plus HTTP 413); policy codes are still matched anywhere in the body. Tests: a nested diagnostic overflow code never masks a top-level policy denial; the explicit top-level overflow cases are unchanged. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/message-v2.ts | 21 ++++++++++++++++--- .../opencode/test/session/message-v2.test.ts | 20 ++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 435d1031db..c42b1b9fad 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -568,6 +568,22 @@ export namespace MessageV2 { return responseErrorCode(responseBody, isEchoPolicyCode) } + /** The provider's own error code (`error.code` or root `code`), never a nested diagnostic field. */ + function providerErrorCode(responseBody: string | undefined): string | undefined { + if (!responseBody) return undefined + try { + const body = JSON.parse(responseBody) as { code?: unknown; error?: { code?: unknown } } + const code = body?.error?.code ?? body?.code + return typeof code === "string" ? code : undefined + } catch { + return undefined + } + } + + function isOverflowCode(code: string | undefined): boolean { + return !!code && ["context_length_exceeded", "content_length_exceeded"].includes(code.toLowerCase()) + } + export function isEchoTransportError(error: Assistant["error"]): boolean { if (error?.name !== "APIError") return false const { statusCode, responseBody, metadata, isRetryable } = error.data @@ -1276,9 +1292,8 @@ export namespace MessageV2 { error: e, }) // altimate_change start — structured policy decisions override overflow text, while explicit overflow codes win - const overflowCode = responseErrorCode(e.responseBody, (code) => - ["context_length_exceeded", "content_length_exceeded"].includes(code.toLowerCase()), - ) + // Only the provider's own code decides overflow; a nested diagnostic code must not mask a policy denial. + const overflowCode = isOverflowCode(providerErrorCode(e.responseBody)) const policyCode = echoPolicyCode(e.responseBody) if (policyCode && e.statusCode !== 413 && !overflowCode) { return new MessageV2.APIError( diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index af207a80f4..4637daf496 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1571,6 +1571,26 @@ describe("session.message-v2.fromError", () => { }) } + for (const code of ["context_length_exceeded", "content_length_exceeded"]) { + test(`a nested diagnostic ${code} never masks a top-level policy denial`, () => { + const result = MessageV2.fromError(new APICallError({ + message: "Request failed", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 400, + isRetryable: false, + responseBody: JSON.stringify({ + error: { code: "policy_violation", message: "maximum context length exceeded" }, + details: { request: { code } }, + }), + }), { providerID }) + + expect(result.name).toBe("APIError") + expect(MessageV2.isEchoTransportError(result)).toBe(false) + expect((result as any).data.metadata.code).toBe("policy_violation") + }) + } + for (const code of ["context_length_exceeded", "content_length_exceeded"]) { test(`explicit ${code} remains overflow despite a nested policy code`, () => { const result = MessageV2.fromError(new APICallError({ From 76a5828481af8227359953dce15102d2fa3bc82e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 13 Oct 2026 01:17:18 -0700 Subject: [PATCH 31/39] fix(analyst): keep processing a request queued during a failed post-commit compaction When a user request was persisted while a post-commit compaction ran and the summariser then failed recoverably, the loop exited: the exit guard published the previous request's committed answer and resolved every waiting caller with it, so the queued request got a stale answer and was never processed. - the generation loop and its exit guard run inside a recovery loop: after a recovery, persisted order is re-read and a newer user request (compaction markers and synthetic continuations excluded) resumes processing in the same generation; callers resolve only after that request has its own reply - a converted failed summary carries the original committed request's `parentID`; the round-20 placement of a recovered answer before the queued request is unchanged - tests: a user queued during a 503 compaction failure, and during two empty summaries, gets tools and its own reply after the original committed answer; callbacks resolve with the queued reply; run/ACP observers see each text once Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/prompt.ts | 21 +++- .../opencode/test/agent/answer-commit.test.ts | 97 ++++++++++++++++++- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5f51f17b61..cd42741da4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -981,9 +981,10 @@ export namespace SessionPrompt { } if (cause) message.metadata = { ...message.metadata, answer_commit_echo_error: cause } delete message.error - // A failed summariser becomes the exiting answer, so clients must no longer hide it as compaction machinery. + // A failed summariser becomes the original request's answer, so clients must no longer hide it as compaction machinery. if (message.summary) { delete message.summary + message.parentID = committedAnswer.userID message.agent = "analyst" message.mode = "analyst" await Session.updateMessage({ ...message, finish: undefined, time: { created: message.time.created } }) @@ -996,6 +997,8 @@ export namespace SessionPrompt { committedAnswer = undefined return true } + // Recovery may finish an earlier request while this generation still has queued work. + recovery: while (true) { let loopFailure: { error: unknown } | undefined try { // altimate_change end @@ -2394,6 +2397,7 @@ export namespace SessionPrompt { loopFailure = { error } } let recovered = false + const recoveredUserID = committedAnswer?.userID if (committedAnswer) { const exiting = [...orderedMessageStream(readMessageOrder())].find((msg) => msg.info.role === "assistant") if (exiting?.info.role === "assistant") { @@ -2405,6 +2409,21 @@ export namespace SessionPrompt { } } if (loopFailure && !recovered) throw loopFailure.error + if (recovered && recoveredUserID) { + // Publishing can itself yield to a queued prompt. Re-read persisted order + // before resolving this generation's callers with the recovered answer. + const order = readMessageOrder() + for (const msg of orderedMessageStream(order)) { + if (compareMessageOrder(order, msg.info.id, recoveredUserID) <= 0) break + if ( + msg.info.role === "user" && + !msg.parts.some((part) => part.type === "compaction") && + !compactionContinuations.has(msg.info.id) + ) continue recovery + } + } + break + } // altimate_change end // altimate_change start — the generation-scoped disposer publishes the // sole normal idle transition after removing this loop from state. Abort diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 76d6de239d..6773d0b3ac 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -431,10 +431,12 @@ for (const scenario of [ "echo-overflow-enabled", "echo-reactive-compaction", "compaction-error", + "compaction-error-queued-user", "compaction-queued-user", "overflow-code-compaction", "compaction-streamed-error", "compaction-empty-summary", + "compaction-empty-summary-queued-user", "compaction-empty-summary-streamed", "compaction-empty-summary-without-commit", "compaction-forbidden", @@ -474,6 +476,7 @@ for (const scenario of [ const queuedUser = scenario === "queued-user" || scenario === "queued-user-reversed-ids" const userAfterCommit = scenario === "new-user-after-commit-reversed-ids" const userAfterCompaction = scenario === "compaction-queued-user" + const userDuringFailedCompaction = scenario === "compaction-error-queued-user" || scenario === "compaction-empty-summary-queued-user" const overflowCodeCompaction = scenario === "overflow-code-compaction" const summaryText = "Retained summary: counted 42 records using records.id." const reversedIDs = scenario.includes("reversed-ids") @@ -493,10 +496,12 @@ for (const scenario of [ "echo-overflow-enabled": "a context overflow during echo publishes the committed output without fatal events", "echo-reactive-compaction": "an echo triggering reactive compaction publishes only the committed text without a continuation", "compaction-error": "a 503 compaction summariser failure after a commit publishes the committed output without fatal events", + "compaction-error-queued-user": "a user queued during a 503 compaction failure receives tools and its own reply after the original committed answer", "compaction-queued-user": "a queued user after successful compaction preserves the summary and receives tools after a new committed-answer message", "overflow-code-compaction": "a 413 content_length_exceeded response triggers compaction and resumes with tools", "compaction-streamed-error": "a streamed partial compaction summary followed by a 503 delivers only the committed answer to ACP and run", "compaction-empty-summary": "two empty compaction summaries after a commit publish the committed output without fatal events", + "compaction-empty-summary-queued-user": "a user queued during two empty compaction summaries receives tools and its own reply after the original committed answer", "compaction-empty-summary-streamed": "two empty compaction summaries after streamed partial text deliver only the committed answer to ACP and run", "compaction-empty-summary-without-commit": "two empty compaction summaries without a commit retain the existing UnknownError", "compaction-forbidden": "a 403 compaction summariser failure after a commit remains fatal", @@ -552,6 +557,7 @@ for (const scenario of [ const renderedAnswer = renderAnswer(args) const originalUserID = reversedIDs ? MessageID.make(`msg_zzzz_original_${chat.id}`) : undefined let queuedUserID: MessageID | undefined + const queuedReplies: Promise[] = [] const insertUser = async (created = Date.now()) => { if (reversedIDs) { const original = MessageV2.get({ sessionID: chat.id, messageID: originalUserID! }) @@ -600,9 +606,22 @@ for (const scenario of [ async (_url: string | URL | Request, init?: RequestInit) => { if (typeof init?.body !== "string") throw new Error("expected a JSON request body") requests.push(JSON.parse(init.body)) - if (requests.length > (priorCommit ? 4 : userDuringEcho || emptySummary || userAfterCompaction || overflowCodeCompaction ? 3 : 2)) + if (requests.length > (userDuringFailedCompaction ? emptySummary ? 4 : 3 : priorCommit ? 4 : userDuringEcho || emptySummary || userAfterCompaction || overflowCodeCompaction ? 3 : 2)) throw new Error("unexpected extra generation") const step = priorCommit && requests.length > 2 ? requests.length - 2 : requests.length + if (step === 2 && userDuringFailedCompaction) { + const queued = await SessionPrompt.prompt({ + sessionID: chat.id, + agent: "analyst", + model: promptModel, + noReply: true, + parts: [{ type: "text", text: "A new question: how many sources?" }], + }) + queuedUserID = queued.info.id + // Register both waiting callers after persistence and before the + // summariser responds, without racing asynchronous prompt setup. + queuedReplies.push(SessionPrompt.loop({ sessionID: chat.id }), SessionPrompt.loop({ sessionID: chat.id })) + } if (step === 1 && overflowCodeCompaction) { return new Response(JSON.stringify({ error: { code: "content_length_exceeded", message: "Request entity too large" } }), { status: 413, @@ -613,7 +632,7 @@ for (const scenario of [ step === 2 && [ "echo-transport-error", "echo-policy-denied", "echo-guardrail-denied", "echo-guardrail-overflow-text", "echo-forbidden", "echo-http-content-filter", - "compaction-error", "compaction-forbidden", "compaction-guardrail-denied", "compaction-content-filter", "echo-overflow-disabled", "echo-overflow-enabled", + "compaction-error", "compaction-error-queued-user", "compaction-forbidden", "compaction-guardrail-denied", "compaction-content-filter", "echo-overflow-disabled", "echo-overflow-enabled", ].includes(scenario) ) { const error = @@ -630,7 +649,7 @@ for (const scenario of [ return new Response(JSON.stringify(guardrail ? { error, orchestrationError: { code: "guardrail_intervened" } } : { error }), { - status: scenario === "echo-transport-error" || scenario === "compaction-error" || guardrail || scenario === "echo-guardrail-overflow-text" + status: scenario === "echo-transport-error" || scenario === "compaction-error" || scenario === "compaction-error-queued-user" || guardrail || scenario === "echo-guardrail-overflow-text" ? 503 : scenario === "echo-forbidden" || scenario === "compaction-forbidden" ? 403 : 400, headers: { "Content-Type": "application/json" }, @@ -663,6 +682,8 @@ for (const scenario of [ .item() : step === 2 && (userAfterCompaction || overflowCodeCompaction) ? reply().text(summaryText).stop().item() + : userDuringFailedCompaction && step === (emptySummary ? 4 : 3) + ? reply().text("3").stop().item() : streamedSummary ? reply().text("Summary so far: ").stop().item() : emptySummary || scenario === "echo-no-text" @@ -790,7 +811,7 @@ for (const scenario of [ if (scenario === "echo-snapshot-error" && echoPrepared) throw new Error("Snapshot tracking failed") return track() }) - const retrySpy = scenario === "compaction-error" || scenario === "echo-guardrail-denied" || scenario === "echo-guardrail-overflow-text" || scenario === "compaction-guardrail-denied" + const retrySpy = scenario === "compaction-error" || scenario === "compaction-error-queued-user" || scenario === "echo-guardrail-denied" || scenario === "echo-guardrail-overflow-text" || scenario === "compaction-guardrail-denied" ? spyOn(SessionRetry, "retryable").mockReturnValue(undefined) : undefined const finishedEchoText: string[][] = [] @@ -943,6 +964,74 @@ for (const scenario of [ } const result = yield* pending if (result.info.role === "assistant") accounting.onPromptResult(result.info) + if (userDuringFailedCompaction) { + if (!queuedUserID) throw new Error("missing queued user request") + const callbacks = yield* Effect.promise(() => Promise.all(queuedReplies)) + expect(callbacks).toHaveLength(2) + for (const reply of [result, ...callbacks]) { + expect(reply.info).toHaveProperty("parentID", queuedUserID) + expect(reply.info).toHaveProperty("finish", "stop") + expect(reply.info).not.toHaveProperty("error") + expect(reply.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual(["3"]) + expect(reply.info.id).toBe(result.info.id) + } + expect(requests).toHaveLength(emptySummary ? 4 : 3) + const summaries = requests.slice(1, emptySummary ? 3 : 2) + for (const request of summaries) { + expect(JSON.stringify(request.messages)).toContain("Provide a detailed prompt for continuing") + expect(request.tools ?? []).toEqual([]) + } + if (emptySummary) expect(summaries[1].messages).toEqual(summaries[0].messages) + const resumed = requests.at(-1)! + expect((resumed.tools ?? []).map((tool) => tool.function.name)).toContain("answer_commit") + expect(JSON.stringify(resumed.messages)).not.toContain(echoInstruction) + expect(JSON.stringify(resumed.messages)).toContain("A new question: how many sources?") + const history = yield* sessions.messages({ sessionID: chat.id }) + const commit = history.find((message) => message.parts.some((part) => part.type === "tool" && part.tool === "answer_commit")) + if (commit?.info.role !== "assistant") throw new Error("missing committed request") + const answers = history.filter((message) => message.info.role === "assistant" && + message.parts.some((part) => part.type === "text" && part.text === renderedAnswer)) + expect(answers).toHaveLength(1) + const answer = answers[0] + expect(answer.info).toMatchObject({ parentID: commit.info.parentID, finish: "stop" }) + expect(answer.info).not.toHaveProperty("summary", true) + expect(answer.info).not.toHaveProperty("error") + expect(answer.info).toHaveProperty("metadata.answer_commit_echo_error.data.message", emptySummary + ? "Compaction summarizer produced no summary text after retry" + : "Compaction summariser rejected the request") + const ordered = Database.use((db) => db + .select({ id: MessageTable.id }) + .from(MessageTable) + .where(eq(MessageTable.session_id, chat.id)) + .orderBy(MessageTable.time_created, sql`rowid`) + .all()) + const answerIndex = ordered.findIndex((message) => message.id === answer.info.id) + expect(answerIndex).toBeGreaterThanOrEqual(0) + expect(ordered.findIndex((message) => message.id === queuedUserID)).toBeGreaterThan(answerIndex) + const modelHistory = resumed.messages as { role: string; content: unknown }[] + const answerTurn = modelHistory.findIndex((message) => message.role === "assistant" && message.content === renderedAnswer) + const queuedTurn = modelHistory.findIndex((message) => message.role === "user" && + JSON.stringify(message.content).includes("A new question: how many sources?")) + expect(answerTurn).toBeGreaterThanOrEqual(0) + expect(queuedTurn).toBeGreaterThan(answerTurn) + expect(modelHistory.findLastIndex((message) => message.role === "user")).toBe(queuedTurn) + expect(textEvents.filter((event) => event.messageID === answer.info.id)).toEqual([ + { messageID: answer.info.id, type: "start", text: "" }, + { messageID: answer.info.id, type: "delta", text: renderedAnswer }, + { messageID: answer.info.id, type: "completion", text: renderedAnswer }, + ]) + // Run prints completed, non-compaction text; ACP consumes the same + // immutable event sequence through its real subscription handler. + expect(textUpdates.filter((part) => part.time?.end && !compactionTextIDs.includes(part.messageID)) + .map((part) => part.text)).toEqual([renderedAnswer, "3"]) + expect(output.content()).toBe("3") + expect(yield* acpText(clientEvents, chat.id)).toEqual([renderedAnswer, "3"]) + expect(sessionErrors).toEqual([]) + expect(assistantUpdates.filter((message) => message.error)).toEqual([]) + expect(accounting.fatal).toBe(false) + expect(requestStarts).toEqual([commit.info.parentID, queuedUserID]) + return + } if (emptySummary) { expect(requests).toHaveLength(3) expect(JSON.stringify(requests[1].messages)).toContain("Provide a detailed prompt for continuing") From fd547236ee6adf32a3049dc0a7b97cfa6b27a199 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 13 Oct 2026 01:21:30 -0700 Subject: [PATCH 32/39] fix(analyst): read the upstream error code inside the gateway wrapper when classifying an overflow Narrowing overflow detection to the provider's own code dropped the one place a real upstream code lives behind the LiteLLM / Altimate Base gateway: `error.provider_specific_fields.error.code` under a generic outer `error.code`. That path is now one of the three consulted (upstream wrapper, `error.code`, root `code`); arbitrary nested diagnostic fields are still ignored. Test: a wrapped upstream `context_length_exceeded` / `content_length_exceeded` is a `ContextOverflowError`; the nested-diagnostic and top-level cases are unchanged. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/message-v2.ts | 15 +++++++++---- .../opencode/test/session/message-v2.test.ts | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index c42b1b9fad..858fd501f7 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -568,13 +568,20 @@ export namespace MessageV2 { return responseErrorCode(responseBody, isEchoPolicyCode) } - /** The provider's own error code (`error.code` or root `code`), never a nested diagnostic field. */ + /** + * The provider's own error code: `error.code`, the root `code`, or the upstream code that the + * LiteLLM / Altimate Base gateway wraps under `error.provider_specific_fields.error.code`. + * Arbitrary nested diagnostic fields are never consulted. + */ function providerErrorCode(responseBody: string | undefined): string | undefined { if (!responseBody) return undefined try { - const body = JSON.parse(responseBody) as { code?: unknown; error?: { code?: unknown } } - const code = body?.error?.code ?? body?.code - return typeof code === "string" ? code : undefined + const body = JSON.parse(responseBody) as { + code?: unknown + error?: { code?: unknown; provider_specific_fields?: { error?: { code?: unknown } } } + } + const candidates = [body?.error?.provider_specific_fields?.error?.code, body?.error?.code, body?.code] + return candidates.find((code): code is string => typeof code === "string") } catch { return undefined } diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 4637daf496..4eb5b79828 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1571,6 +1571,27 @@ describe("session.message-v2.fromError", () => { }) } + for (const code of ["context_length_exceeded", "content_length_exceeded"]) { + test(`an upstream ${code} wrapped by the gateway under provider_specific_fields is still an overflow`, () => { + const result = MessageV2.fromError(new APICallError({ + message: "Request failed", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 400, + isRetryable: false, + responseBody: JSON.stringify({ + error: { + code: "invalid_request_error", + message: "upstream request failed", + provider_specific_fields: { error: { code, message: "maximum context length exceeded" } }, + }, + }), + }), { providerID }) + + expect(result.name).toBe("ContextOverflowError") + }) + } + for (const code of ["context_length_exceeded", "content_length_exceeded"]) { test(`a nested diagnostic ${code} never masks a top-level policy denial`, () => { const result = MessageV2.fromError(new APICallError({ From b924ed42b7ffdb630bdfbf8494be69d96b8d2859 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 13 Oct 2026 19:16:54 -0700 Subject: [PATCH 33/39] refactor(session): keep the request-started event at module scope - `RequestStarted` is only used inside `prompt.ts`; it now lives as a non-exported top-level constant instead of extending the `SessionPrompt` namespace, per the package convention for namespace-private helpers. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/prompt.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index be99e285b3..44eea0aa9a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -129,14 +129,15 @@ IMPORTANT: const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` +// altimate_change start - request identity is independent of compaction message adjacency +const RequestStarted = BusEvent.define( + "session.request.started", + z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), +) +// altimate_change end + export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) - // altimate_change start - request identity is independent of compaction message adjacency - export const RequestStarted = BusEvent.define( - "session.request.started", - z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), - ) - // altimate_change end // altimate_change start — how long a turn will wait for the workspace skill // sync before proceeding without it. See the block in `prompt`. From 7b49d731b18e8c970a0b6535e99d919d87c4e93f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 13 Oct 2026 20:48:43 -0700 Subject: [PATCH 34/39] fix(analyst): a hard time-budget deadline still delivers the committed answer - `run --time-budget` marks its hard-deadline abort (`x-altimate-run-budget: hard-deadline`), the session abort route turns that into a typed `RunBudget.HarnessDeadline` abort reason, and the committed-answer guard treats only that reason as non-fatal: the stored `answer_commit` text is published once through the existing path and the message records deadline provenance. A user cancellation keeps today's behaviour (no finalization). - The following budget-finalization prompt returns that persisted answer without a model turn or a second publication when the finalization directive and control are the synthetic ones and no user request intervened. - tests: deadline abort during the echo publishes the committed text exactly once; user cancellation does not finalize; budget finalization after a hard abort reuses the commit without a model turn and releases its trace binding; the CLI abort carries the deadline marker. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/altimate/run-budget.ts | 7 ++ packages/opencode/src/cli/cmd/run.ts | 7 +- .../opencode/src/server/routes/session.ts | 8 ++- packages/opencode/src/session/prompt.ts | 36 ++++++++-- .../opencode/test/agent/answer-commit.test.ts | 65 +++++++++++++++++++ .../test/cli/run/queued-time-budget.test.ts | 32 +++++++++ 6 files changed, 148 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/altimate/run-budget.ts b/packages/opencode/src/altimate/run-budget.ts index 8b6d8bc51f..45b1fb081f 100644 --- a/packages/opencode/src/altimate/run-budget.ts +++ b/packages/opencode/src/altimate/run-budget.ts @@ -4,6 +4,13 @@ import z from "zod" const generations = new Map void>() +/** A harness deadline stops generation but may still deliver an already committed answer. */ +export class HarnessDeadline extends DOMException { + constructor() { + super("Run time budget hard deadline reached", "AbortError") + } +} + /** Register only the active provider request; disposing cannot remove a newer request. */ export function registerGeneration(sessionID: string, interrupt: () => void): () => void { generations.set(sessionID, interrupt) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 6682e5b261..8b5f5f5032 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -856,12 +856,17 @@ You are speaking to a non-technical business executive. Follow these rules stric const abortForTimeBudget = () => { if (!sessionID || timeAbortRequested) return timeAbortRequested = true - timeBudgetAbort = sdk.session.abort({ sessionID }, { signal: AbortSignal.timeout(5_000) }).then((result) => { + // altimate_change start — distinguish harness deadlines from user cancellation on the server + timeBudgetAbort = sdk.session.abort( + { sessionID }, + { signal: AbortSignal.timeout(5_000), headers: { "x-altimate-run-budget": "hard-deadline" } }, + ).then((result) => { if (result.error) throw result.error }).catch((e) => { accounting.onSessionError("TimeBudgetAbortError", String(e)) eventAbort.abort() }) + // altimate_change end } const checkTimeBudget = () => { if (!sessionID || !workActive || timeExhausted()) return diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index dcb9fe93e0..10584cded3 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -22,6 +22,7 @@ import { TraceContext } from "@/altimate/observability/trace-context" // altimate_change end // altimate_change start — run-scoped request interruption and effort overrides import { withBudgetControl } from "@/altimate/session/run-budget-control" +import { RunBudget } from "@/altimate/run-budget" // altimate_change end // altimate_change start — upstream_fix: bridge merge wired routes to Effect-TS Permission, but the runtime // asks come from PermissionNext. Use PermissionNext here so this deprecated reply route hits the same @@ -400,7 +401,12 @@ export const SessionRoutes = lazy(() => }), ), async (c) => { - await SessionPrompt.cancel(c.req.valid("param").sessionID) + // altimate_change start — carry harness deadline provenance through the SDK abort endpoint + await SessionPrompt.cancel( + c.req.valid("param").sessionID, + c.req.header("x-altimate-run-budget") === "hard-deadline" ? new RunBudget.HarnessDeadline() : undefined, + ) + // altimate_change end return c.json(true) }, ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 44eea0aa9a..a129f89493 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -115,6 +115,10 @@ import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // altima // altimate_change start — keyless public Zen predicate (flat module) import { isPublicZen } from "@/provider/public-zen" // altimate_change end +// altimate_change start — distinguish harness deadlines and reuse their committed answer during finalization +import { RunBudget } from "@/altimate/run-budget" +import * as RunBudgetEffort from "@/altimate/session/run-budget-effort" +// altimate_change end // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -585,7 +589,7 @@ export namespace SessionPrompt { } // altimate_change start — SessionStatus.set became async in v1.4.0; await so idle state actually flushes - export async function cancel(sessionID: SessionID) { + export async function cancel(sessionID: SessionID, reason?: RunBudget.HarnessDeadline) { log.info("cancel", { sessionID }) const s = state() const match = s[sessionID] @@ -598,7 +602,7 @@ export namespace SessionPrompt { // arrive synchronously after cancel() and must start a fresh generation, // never queue its callback on the signal that was just aborted. match.closing = true - match.abort.abort() + match.abort.abort(reason) if (!match.loopOwned) { // shell() also uses start(), but it has no loop disposer when no prompt // callbacks are queued. That owner must restore idle directly. @@ -909,16 +913,18 @@ export namespace SessionPrompt { deferredText: MessageV2.TextPart[] = [], error = message.error, ) => { + // altimate_change start — a work deadline may publish the answer already committed before cancellation + const deadline = abort.reason instanceof RunBudget.HarnessDeadline if ( - !committedAnswer || abort.aborted || + !committedAnswer || (abort.aborted && !deadline) || compareMessageOrder(readMessageOrder(), message.id, committedAnswer.userID) <= 0 ) return false - // Keep aborts and HTTP policy rejections fatal even if a later operation throws another error. + // Keep user aborts and HTTP policy rejections fatal even if a later operation throws another error. if ( message.finish === "content-filter" || [message.error, error].some( (cause) => - cause?.name === "MessageAbortedError" || + (cause?.name === "MessageAbortedError" && !deadline) || cause?.name === "ContentFilterError" || (cause && ["APIError", "UnknownError", "ProviderAuthError"].includes(cause.name) && @@ -926,6 +932,7 @@ export namespace SessionPrompt { ) ) return false + // altimate_change end // Once the echo has started, this answer still belongs to the earlier request. // Publish it before the loop continues to any user request queued during generation. const recoveryCause = error ?? message.metadata?.answer_commit_echo_error @@ -981,6 +988,9 @@ export namespace SessionPrompt { if (old.id !== part.id) await Session.removePart({ sessionID, messageID: message.id, partID: old.id }) } if (cause) message.metadata = { ...message.metadata, answer_commit_echo_error: cause } + // altimate_change start — the follow-up budget prompt reuses this terminal answer without another publication + if (deadline) message.metadata = { ...message.metadata, answer_commit_deadline: true } + // altimate_change end delete message.error // A failed summariser becomes the original request's answer, so clients must no longer hide it as compaction machinery. if (message.summary) { @@ -1098,6 +1108,22 @@ export namespace SessionPrompt { // altimate_change start — always track the current agent name so early breaks still report it if (lastUser.agent) sessionAgentName = lastUser.agent // altimate_change end + // altimate_change start — budget finalization acknowledges a deadline-delivered commit without a model turn + const finalizationParts = msgs.find((msg) => msg.info.id === lastUser.id)?.parts + if ( + RunBudgetEffort.toolsDisabled(sessionID) && + lastAssistant?.metadata?.answer_commit_deadline === true && + lastAssistant.finish === "stop" && !lastAssistant.error && + lastUser.agent === "analyst" && (lastUser.format?.type ?? "text") === "text" && + finalizationParts?.length === 1 && + finalizationParts[0].type === "text" && finalizationParts[0].synthetic && + finalizationParts[0].text === SessionTermination.REPLY_AFTER_BUDGET && + !msgs.some((msg) => + msg.info.role === "user" && msg.info.id !== lastUser.id && + compareMessageOrder(messageOrder, msg.info.id, lastAssistant!.id) > 0, + ) + ) break + // altimate_change end // altimate_change start — upstream_fix: a provider can finish with // "stop" while still emitting tool parts; those tools must be replayed // into the next loop instead of terminating the session. diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 6773d0b3ac..fd488155d3 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -7,6 +7,7 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { renderAnswer } from "../../src/altimate/tools/answer-commit" +import { TraceContext } from "../../src/altimate/observability/trace-context" import { ToolLookupTool } from "../../src/altimate/tools/tool-lookup" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { OutputCollector } from "../../src/cli/cmd/run-output" @@ -17,6 +18,8 @@ import { Provider } from "../../src/provider/provider" import { Session } from "../../src/session/session" import { Session as LegacySession } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRoutes } from "../../src/server/routes/session" +import { SessionTermination } from "../../src/session/termination" import { SessionCompaction } from "../../src/session/compaction" import { MessageV2 } from "../../src/session/message-v2" import { SessionSummary } from "../../src/session/summary" @@ -446,6 +449,9 @@ for (const scenario of [ "echo-overflow-disabled", "echo-preparation-error", "echo-aborted", + "echo-harness-deadline", + "echo-user-cancelled", + "echo-budget-finalization", "echo-content-filter", "echo-http-content-filter", "echo-other", @@ -511,6 +517,9 @@ for (const scenario of [ "echo-overflow-disabled": "an unrecoverable context overflow after a commit publishes the committed output and records the cause", "echo-preparation-error": "a local echo preparation failure after a commit is not recovered", "echo-aborted": "an aborted echo retains its fatal error without publishing the committed output", + "echo-harness-deadline": "a harness deadline abort during echo publishes the stored committed text exactly once", + "echo-user-cancelled": "a user cancellation during echo does not finalize the stored committed answer", + "echo-budget-finalization": "budget finalization after a hard abort reuses the committed answer without starting a model turn", "echo-content-filter": "a content-filtered echo retains its fatal error and original text", "echo-http-content-filter": "an HTTP content-filtered echo retains its fatal error without publishing the committed output", "echo-other": "an echo with an other finish keeps other and publishes the committed output", @@ -661,6 +670,15 @@ for (const scenario of [ if (step === 2 && (scenario === "echo-aborted" || scenario === "compaction-aborted")) { throw new DOMException("Echo request aborted", "AbortError") } + if (step === 2 && ["echo-harness-deadline", "echo-user-cancelled", "echo-budget-finalization"].includes(scenario)) { + const response = await SessionRoutes().request(`/${chat.id}/abort`, { + method: "POST", + headers: scenario === "echo-user-cancelled" ? {} : { "x-altimate-run-budget": "hard-deadline" }, + }) + expect(response.status).toBe(200) + expect(init?.signal?.aborted).toBe(true) + throw init!.signal!.reason + } if ( (scenario === "new-user" && requests.length === 1) || (userDuringEcho && requests.length === 2) @@ -964,6 +982,53 @@ for (const scenario of [ } const result = yield* pending if (result.info.role === "assistant") accounting.onPromptResult(result.info) + if (["echo-harness-deadline", "echo-user-cancelled", "echo-budget-finalization"].includes(scenario)) { + if (scenario === "echo-user-cancelled") { + expect(result.info).toHaveProperty("error.name", "MessageAbortedError") + expect(result.parts.filter((part) => part.type === "text")).toEqual([]) + expect(textUpdates.filter((part) => part.time?.end)).toEqual([]) + expect(textDeltas).toEqual([]) + expect(requests).toHaveLength(2) + return + } + if (scenario === "echo-budget-finalization") { + const messageID = MessageID.ascending() + const response = yield* Effect.promise(() => Promise.resolve(SessionRoutes().request(`/${chat.id}/message`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-altimate-run-budget": "finalization", + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + }, + body: JSON.stringify({ + messageID, + agent: "analyst", + model: promptModel, + parts: [{ type: "text", text: SessionTermination.REPLY_AFTER_BUDGET, synthetic: true }], + }), + }))) + expect(response.status).toBe(200) + const finalized = yield* Effect.promise(() => response.json()) + expect(requests).toHaveLength(2) + expect(TraceContext.forMessage(messageID)).toBeUndefined() + expect(finalized.info.id).toBe(result.info.id) + expect(finalized.parts.filter((part: MessageV2.Part) => part.type === "text").map((part: MessageV2.TextPart) => part.text)) + .toEqual([renderedAnswer]) + } + expect(result.info).not.toHaveProperty("error") + expect(result.info).toHaveProperty("finish", "stop") + expect(result.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual([renderedAnswer]) + expect(textUpdates.filter((part) => part.time?.end).map((part) => part.text)).toEqual([renderedAnswer]) + expect(textDeltas.map((part) => part.text)).toEqual([renderedAnswer]) + expect(output.content()).toBe(renderedAnswer) + expect(requests).toHaveLength(2) + expect(requests[1].messages.at(-1)).toEqual({ role: "user", content: echoInstruction }) + const history = yield* sessions.messages({ sessionID: chat.id }) + const commit = history.flatMap((message) => message.parts) + .find((part) => part.type === "tool" && part.tool === "answer_commit") + expect(commit).toHaveProperty("state.metadata.committed_text", renderedAnswer) + return + } if (userDuringFailedCompaction) { if (!queuedUserID) throw new Error("missing queued user request") const callbacks = yield* Effect.promise(() => Promise.all(queuedReplies)) diff --git a/packages/opencode/test/cli/run/queued-time-budget.test.ts b/packages/opencode/test/cli/run/queued-time-budget.test.ts index a028b32551..99558790d2 100644 --- a/packages/opencode/test/cli/run/queued-time-budget.test.ts +++ b/packages/opencode/test/cli/run/queued-time-budget.test.ts @@ -33,6 +33,37 @@ test("hard deadline aborts a queued session without a post-subscription busy eve }) expect(calls).toEqual(["ses_queued"]) }) + +test("hard deadline abort carries the harness deadline marker", async () => { + const source = await fs.readFile(new URL("../../../src/cli/cmd/run.ts", import.meta.url), "utf8") + const start = source.indexOf(" const abortForTimeBudget = () => {") + const end = source.indexOf(" const checkTimeBudget =", start) + expect(start).toBeGreaterThan(0) + let abortOptions: { signal: AbortSignal; headers?: Record } | undefined + const setup = new Function( + "sdk", + ` + const sessionID = "ses_deadline_marker" + let timeAbortRequested = false + let timeBudgetAbort + const accounting = { onSessionError() {} } + const eventAbort = new AbortController() + ${source.slice(start, end)} + abortForTimeBudget() + return timeBudgetAbort + `, + ) + await setup({ + session: { + abort: async (_: unknown, options: NonNullable) => { + abortOptions = options + return {} + }, + }, + }) + expect(abortOptions?.headers?.["x-altimate-run-budget"]).toBe("hard-deadline") + expect(abortOptions?.signal.aborted).toBe(false) +}) // altimate_change end // altimate_change start — the request lifetime also ends when queued work never becomes busy @@ -51,6 +82,7 @@ for (const command of [undefined, "inspect"]) { const files = [], audienceSystem = undefined const eventAbort = new AbortController() const startWorkClock = () => {} + const beginRequest = () => {} const budgetHeaders = () => ({}) const Telemetry = { startupReady() {} } ${source.slice(start, end)} From 349c1d2f1bcfcad45b254f27712a8a19aefbd0c0 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 13 Oct 2026 21:34:30 -0700 Subject: [PATCH 35/39] fix(analyst): the `--max-turns` echo allowance belongs to the request that committed - `run` tracked the committed answer as one global flag, so a request queued by another client between the echo dispatch and its `step-start` reset it and the exhausted-budget guard aborted the earlier request's permitted echo (a non-deadline abort, so the stored commit was never published). Commits are now tracked per request id through the existing `assistantRequests` ownership, and the guard checks the owner of the arriving step; a queued request neither clears nor inherits another request's allowance. Ordinary turn counting and refusal are unchanged. - `message-v2.ts`: the echo error helpers (`echoNetworkCodes`, `echoPolicyCodes` and their parsers) move to non-exported module scope per the package convention; no external imports existed. - test: a request queued before the committed echo's `step-start` leaves the echo to publish its committed text and is itself refused for the exhausted budget. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/cli/cmd/run.ts | 18 ++- packages/opencode/src/session/message-v2.ts | 144 +++++++++--------- .../test/cli/run/budget-finalization.test.ts | 94 +++++++++++- 3 files changed, 179 insertions(+), 77 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 8b5f5f5032..319266903d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -729,6 +729,8 @@ You are speaking to a non-technical business executive. Follow these rules stric const accounting = RunAccounting.create() let activeRequestID: string | undefined const assistantRequests = new Map() + // A queued request must not revoke the in-flight echo owned by an earlier request. + const committedRequests = new Set() const userRequests = new Map() const seenRequests = new Set() const beginRequest = (messageID: string) => { @@ -1052,11 +1054,15 @@ You are speaking to a non-technical business executive. Follow these rules stric if (part.type === "tool" && !accounting.isCompactionStep(part.messageID)) lastToolStep = step if ( part.type === "tool" && part.tool === "answer_commit" && - (part.state.status === "completed" || part.state.status === "error") && - activeRequestID !== undefined && assistantRequests.get(part.messageID) === activeRequestID + (part.state.status === "completed" || part.state.status === "error") ) { - // Only this request's successful commit can exempt its echo; a failed retry revokes it. - accounting.onAnswerCommit(part.state.status) + const requestID = assistantRequests.get(part.messageID) + if (requestID !== undefined) { + // Success and failed retries affect only the request that owns this tool. + if (part.state.status === "completed") committedRequests.add(requestID) + else committedRequests.delete(requestID) + if (requestID === activeRequestID) accounting.onAnswerCommit(part.state.status) + } } // altimate_change end @@ -1111,9 +1117,11 @@ You are speaking to a non-technical business executive. Follow these rules stric // the owning message's agent is resolved via the message.updated lookup // above, so compacting models are not differentially charged turns. const counted = accounting.onStepStart(part.messageID) + const requestID = assistantRequests.get(part.messageID) + const committedAnswer = requestID !== undefined && committedRequests.has(requestID) // altimate_change start — the time-budget abort already owns the interrupted work phase if ( - counted && maxTurns && accounting.turnCount > maxTurns && !accounting.committedAnswer && !budgetAborted && + counted && maxTurns && accounting.turnCount > maxTurns && !committedAnswer && !budgetAborted && (!timeExhausted() || options?.turnLimit !== undefined) ) { stopTimeBudget() diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 858fd501f7..0fe610ef99 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -27,6 +27,79 @@ interface FetchDecompressionError extends Error { path: string } +// altimate_change start — keep committed-echo parsing helpers private at module scope +const echoNetworkCodes = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "ESOCKETTIMEDOUT", + "EPIPE", + "ENOTFOUND", + "EAI_AGAIN", + "UND_ERR_SOCKET", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", +]) + +const echoPolicyCodes = new Set([ + "policy_violation", + "content_filter", + "content_policy", + "content_policy_violation", + "guardrail_intervened", +]) + +function isEchoPolicyCode(code: string | undefined): boolean { + if (!code) return false + return echoPolicyCodes.has(code.toLowerCase()) || /^(content_policy.*|(safety|moderation)_.+)$/i.test(code) +} + +function responseErrorCode(responseBody: string | undefined, matches: (code: string) => boolean): string | undefined { + if (!responseBody) return undefined + const find = (value: unknown): string | undefined => { + if (!value || typeof value !== "object") return undefined + for (const [key, child] of Object.entries(value)) { + if (key === "code" && typeof child === "string" && matches(child)) return child + const nested = find(child) + if (nested) return nested + } + } + try { + return find(JSON.parse(responseBody)) + } catch { + return undefined + } +} + +function echoPolicyCode(responseBody: string | undefined): string | undefined { + return responseErrorCode(responseBody, isEchoPolicyCode) +} + +/** + * The provider's own error code: `error.code`, the root `code`, or the upstream code that the + * LiteLLM / Altimate Base gateway wraps under `error.provider_specific_fields.error.code`. + * Arbitrary nested diagnostic fields are never consulted. + */ +function providerErrorCode(responseBody: string | undefined): string | undefined { + if (!responseBody) return undefined + try { + const body = JSON.parse(responseBody) as { + code?: unknown + error?: { code?: unknown; provider_specific_fields?: { error?: { code?: unknown } } } + } + const candidates = [body?.error?.provider_specific_fields?.error?.code, body?.error?.code, body?.code] + return candidates.find((code): code is string => typeof code === "string") + } catch { + return undefined + } +} + +function isOverflowCode(code: string | undefined): boolean { + return !!code && ["context_length_exceeded", "content_length_exceeded"].includes(code.toLowerCase()) +} +// altimate_change end + export namespace MessageV2 { export function isMedia(mime: string) { return mime.startsWith("image/") || mime === "application/pdf" @@ -520,77 +593,6 @@ export namespace MessageV2 { export type Assistant = z.infer // altimate_change start — recover committed echoes only from transient transport failures - const echoNetworkCodes = new Set([ - "ECONNRESET", - "ECONNREFUSED", - "ETIMEDOUT", - "ESOCKETTIMEDOUT", - "EPIPE", - "ENOTFOUND", - "EAI_AGAIN", - "UND_ERR_SOCKET", - "UND_ERR_CONNECT_TIMEOUT", - "UND_ERR_HEADERS_TIMEOUT", - "UND_ERR_BODY_TIMEOUT", - ]) - - const echoPolicyCodes = new Set([ - "policy_violation", - "content_filter", - "content_policy", - "content_policy_violation", - "guardrail_intervened", - ]) - - function isEchoPolicyCode(code: string | undefined): boolean { - if (!code) return false - return echoPolicyCodes.has(code.toLowerCase()) || /^(content_policy.*|(safety|moderation)_.+)$/i.test(code) - } - - function responseErrorCode(responseBody: string | undefined, matches: (code: string) => boolean): string | undefined { - if (!responseBody) return undefined - const find = (value: unknown): string | undefined => { - if (!value || typeof value !== "object") return undefined - for (const [key, child] of Object.entries(value)) { - if (key === "code" && typeof child === "string" && matches(child)) return child - const nested = find(child) - if (nested) return nested - } - } - try { - return find(JSON.parse(responseBody)) - } catch { - return undefined - } - } - - function echoPolicyCode(responseBody: string | undefined): string | undefined { - return responseErrorCode(responseBody, isEchoPolicyCode) - } - - /** - * The provider's own error code: `error.code`, the root `code`, or the upstream code that the - * LiteLLM / Altimate Base gateway wraps under `error.provider_specific_fields.error.code`. - * Arbitrary nested diagnostic fields are never consulted. - */ - function providerErrorCode(responseBody: string | undefined): string | undefined { - if (!responseBody) return undefined - try { - const body = JSON.parse(responseBody) as { - code?: unknown - error?: { code?: unknown; provider_specific_fields?: { error?: { code?: unknown } } } - } - const candidates = [body?.error?.provider_specific_fields?.error?.code, body?.error?.code, body?.code] - return candidates.find((code): code is string => typeof code === "string") - } catch { - return undefined - } - } - - function isOverflowCode(code: string | undefined): boolean { - return !!code && ["context_length_exceeded", "content_length_exceeded"].includes(code.toLowerCase()) - } - export function isEchoTransportError(error: Assistant["error"]): boolean { if (error?.name !== "APIError") return false const { statusCode, responseBody, metadata, isRetryable } = error.data diff --git a/packages/opencode/test/cli/run/budget-finalization.test.ts b/packages/opencode/test/cli/run/budget-finalization.test.ts index 6a14bf3286..d622fa83ce 100644 --- a/packages/opencode/test/cli/run/budget-finalization.test.ts +++ b/packages/opencode/test/cli/run/budget-finalization.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Fiber } from "effect" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" import fs from "node:fs/promises" import path from "node:path" import { RunAccounting } from "@/cli/cmd/run-accounting" @@ -466,6 +467,97 @@ describe("opencode run: budget finalization subprocess", () => { } // altimate_change end // altimate_change start — a completed answer gets one tool-free echo beyond the work-turn limit + cliIt.concurrent( + "a request queued before the committed echo step-start preserves its output and is refused for the exhausted budget", + ({ llm, opencode }) => + Effect.gen(function* () { + const committed = "Committed revenue before the queued request: 42" + const echoDispatched = Promise.withResolvers() + const releaseEcho = Promise.withResolvers() + let generations = 0 + const provider = yield* Effect.acquireRelease( + Effect.sync(() => Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 0, + async fetch(request) { + const body = await request.text() + if (!body.includes("Generate a title for this conversation") && ++generations === 2) { + // Hold even the response headers: the provider cannot emit step-start yet. + echoDispatched.resolve() + await releaseEcho.promise + } + return fetch(`${llm.url}/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + signal: request.signal, + }) + }, + })), + (server) => Effect.sync(() => { + releaseEcho.resolve() + server.stop(true) + }), + ) + yield* llm.tool("answer_commit", { + fields: [{ name: "", value: committed }], + answer_only: true, + basis: "Summed orders.amount over all orders at order grain.", + }) + yield* llm.text("The provider echo must be replaced by the stored commit.") + yield* llm.pushMatch(({ body }) => !isFinalization(body), reply().reason("The queued request must be refused.").hang()) + yield* llm.textMatch(({ body }) => isFinalization(body), "No budget remains for the queued request.") + const server = yield* opencode.serve({ + env: { + ALTIMATE_ANSWER_COMMIT: "1", + // An attached run cannot resolve --agent locally, so the server defaults to the analyst. + OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...testProviderConfig(`${provider.url}v1`), default_agent: "analyst" }), + }, + bunRun: true, + }) + const sdk = createOpencodeClient({ baseUrl: server.url, throwOnError: true }) + const session = yield* Effect.promise(() => sdk.session.create({ title: "Queued echo budget" })) + const sessionID = session.data!.id + const run = yield* opencode.run("Commit the revenue answer.", { + format: "json", + extraArgs: ["--attach", server.url, "--session", sessionID, "--max-turns", "1", "--no-trace"], + env: { ALTIMATE_ANSWER_COMMIT: "1" }, + timeoutMs: 60_000, + bunRun: true, + }).pipe(Effect.forkChild) + yield* Effect.promise(() => echoDispatched.promise).pipe(Effect.timeout("30 seconds")) + // noReply persists the foreign request and publishes its boundary; the running loop will pick it up. + const queued = yield* Effect.promise(() => sdk.session.prompt({ + sessionID, + agent: "analyst", + model: { providerID: "test", modelID: "test-model" }, + noReply: true, + parts: [{ type: "text", text: "Now answer a different question." }], + })) + releaseEcho.resolve() + const result = yield* Fiber.join(run) + opencode.expectExit(result, 1) + const events = opencode.parseJsonEvents(result.stdout) + const budgetIndex = events.findIndex((event) => event.type === "budget_exhausted") + expect(budgetIndex).toBeGreaterThan(-1) + const beforeBudget = events.slice(0, budgetIndex) + expect(beforeBudget.filter((event) => event.type === "step_start")).toHaveLength(2) + expect(beforeBudget.filter((event) => event.type === "text").map((event) => (event.part as { text: string }).text)) + .toEqual([committed]) + expect(events.filter((event) => event.type === "budget_exhausted")).toHaveLength(1) + const messages = yield* Effect.promise(() => sdk.session.messages({ sessionID })) + const echo = messages.data!.find((message) => message.parts.some((part) => part.type === "text" && part.text === committed))! + expect(echo.info).toMatchObject({ role: "assistant", finish: "stop" }) + expect(echo.info).not.toHaveProperty("parentID", queued.data!.info.id) + expect(messages.data!.map((message) => message.info)).toContainEqual(expect.objectContaining({ + role: "assistant", + parentID: queued.data!.info.id, + error: expect.objectContaining({ name: "MessageAbortedError" }), + })) + }), + 90_000, + ) cliIt.concurrent( "answer_commit on --max-turns 1 publishes the committed output on its extra echo turn", ({ llm, opencode }) => From 6f5345f405ea2a02287f2c04105e9dea94a44f82 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 13 Oct 2026 21:48:45 -0700 Subject: [PATCH 36/39] fix(analyst): budget finalization reuses a committed answer delivered while the hard deadline arrives - The finalization turn previously reused the stored commit only when the deadline reason was present at publication time; a deadline arriving during publication left `answer_commit_deadline` unset and the follow-up prompt regenerated. The delivered commit is now always marked (`answer_commit_finalized`) and the budget-finalization prompt reuses it without a model turn in both orders. - test: budget finalization reuses the committed answer when the hard deadline arrives during publication (abort issued from the publication hook, exactly once). Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/prompt.ts | 8 +++---- .../opencode/test/agent/answer-commit.test.ts | 21 +++++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a129f89493..48cfa4f933 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -988,8 +988,8 @@ export namespace SessionPrompt { if (old.id !== part.id) await Session.removePart({ sessionID, messageID: message.id, partID: old.id }) } if (cause) message.metadata = { ...message.metadata, answer_commit_echo_error: cause } - // altimate_change start — the follow-up budget prompt reuses this terminal answer without another publication - if (deadline) message.metadata = { ...message.metadata, answer_commit_deadline: true } + // altimate_change start — reuse delivered commits even when the deadline arrives during publication + message.metadata = { ...message.metadata, answer_commit_finalized: true } // altimate_change end delete message.error // A failed summariser becomes the original request's answer, so clients must no longer hide it as compaction machinery. @@ -1108,11 +1108,11 @@ export namespace SessionPrompt { // altimate_change start — always track the current agent name so early breaks still report it if (lastUser.agent) sessionAgentName = lastUser.agent // altimate_change end - // altimate_change start — budget finalization acknowledges a deadline-delivered commit without a model turn + // altimate_change start — budget finalization acknowledges a delivered commit without a model turn const finalizationParts = msgs.find((msg) => msg.info.id === lastUser.id)?.parts if ( RunBudgetEffort.toolsDisabled(sessionID) && - lastAssistant?.metadata?.answer_commit_deadline === true && + lastAssistant?.metadata?.answer_commit_finalized === true && lastAssistant.finish === "stop" && !lastAssistant.error && lastUser.agent === "analyst" && (lastUser.format?.type ?? "text") === "text" && finalizationParts?.length === 1 && diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index fd488155d3..32166e9a5f 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -452,6 +452,7 @@ for (const scenario of [ "echo-harness-deadline", "echo-user-cancelled", "echo-budget-finalization", + "echo-budget-finalization-publishing", "echo-content-filter", "echo-http-content-filter", "echo-other", @@ -474,6 +475,7 @@ for (const scenario of [ ] as const) { const structured = scenario.startsWith("json_schema") const priorCommit = scenario === "json_schema-history" + const budgetFinalization = scenario.startsWith("echo-budget-finalization") const tinyCap = scenario === "echo-capped" || scenario === "echo-under-cap" const compacting = scenario.startsWith("compaction-") const emptySummary = scenario.startsWith("compaction-empty-summary") @@ -520,6 +522,7 @@ for (const scenario of [ "echo-harness-deadline": "a harness deadline abort during echo publishes the stored committed text exactly once", "echo-user-cancelled": "a user cancellation during echo does not finalize the stored committed answer", "echo-budget-finalization": "budget finalization after a hard abort reuses the committed answer without starting a model turn", + "echo-budget-finalization-publishing": "budget finalization reuses the committed answer when the hard deadline arrives during publication", "echo-content-filter": "a content-filtered echo retains its fatal error and original text", "echo-http-content-filter": "an HTTP content-filtered echo retains its fatal error without publishing the committed output", "echo-other": "an echo with an other finish keeps other and publishes the committed output", @@ -815,11 +818,20 @@ for (const scenario of [ return result }, processCompaction)) : undefined - const commitSpy = userAfterCommit + let publicationAborts = 0 + const commitSpy = userAfterCommit || scenario === "echo-budget-finalization-publishing" ? spyOn(LegacySession, "updatePart").mockImplementation(Object.assign(async (part: MessageV2.Part) => { const result = await updatePart(part) - if (part.type === "tool" && part.tool === "answer_commit" && part.state.status === "completed" && !queuedUserID) + if (userAfterCommit && part.type === "tool" && part.tool === "answer_commit" && part.state.status === "completed" && !queuedUserID) await insertUser() + if (scenario === "echo-budget-finalization-publishing" && requests.length === 2 && part.type === "text" && part.time?.end) { + publicationAborts++ + const response = await SessionRoutes().request(`/${chat.id}/abort`, { + method: "POST", + headers: { "x-altimate-run-budget": "hard-deadline" }, + }) + expect(response.status).toBe(200) + } return result }, updatePart)) : undefined @@ -982,7 +994,8 @@ for (const scenario of [ } const result = yield* pending if (result.info.role === "assistant") accounting.onPromptResult(result.info) - if (["echo-harness-deadline", "echo-user-cancelled", "echo-budget-finalization"].includes(scenario)) { + if (budgetFinalization || ["echo-harness-deadline", "echo-user-cancelled"].includes(scenario)) { + if (scenario === "echo-budget-finalization-publishing") expect(publicationAborts).toBe(1) if (scenario === "echo-user-cancelled") { expect(result.info).toHaveProperty("error.name", "MessageAbortedError") expect(result.parts.filter((part) => part.type === "text")).toEqual([]) @@ -991,7 +1004,7 @@ for (const scenario of [ expect(requests).toHaveLength(2) return } - if (scenario === "echo-budget-finalization") { + if (budgetFinalization) { const messageID = MessageID.ascending() const response = yield* Effect.promise(() => Promise.resolve(SessionRoutes().request(`/${chat.id}/message`, { method: "POST", From a26cae94d1a8add9a0766289f771e82e9da347e9 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 13 Oct 2026 22:09:44 -0700 Subject: [PATCH 37/39] fix(analyst): request ownership survives compaction continuations; an explicit policy code beats a 413 status - The proactive compaction continuation is an internal user row without a parent request, so the per-request `--max-turns` echo allowance recorded no owner for the post-compaction echo and `--max-turns=N` could abort it and lose the committed answer. The prompt loop now publishes an awaited `session.request.continued` event naming the parent for compaction markers, continuations and replays; the run ownership helper follows it to the original request id. Real request boundaries keep their own ids; compaction itself is unchanged. - `MessageV2.fromError` no longer lets an HTTP 413 status override an explicit policy code: a `policy_violation` on 413 stays a fatal policy error instead of a recoverable overflow that would publish the committed output; a 413 without a policy code is still an overflow. - tests: ownership preserved across proactive compaction with no queued request, a request queued before the marker, and one queued during the summary; an ordinary request compacted after a committed echo keeps its own over-budget ownership; 413 with and without a policy code. Co-Authored-By: Claude Fable 5.1 --- .../opencode/src/cli/cmd/run-accounting.ts | 35 ++++++++++ packages/opencode/src/cli/cmd/run.ts | 43 +++++++------ packages/opencode/src/session/message-v2.ts | 3 +- packages/opencode/src/session/prompt.ts | 23 ++++++- .../test/cli/run/budget-finalization.test.ts | 64 +++++++++++++++++++ .../opencode/test/session/message-v2.test.ts | 16 ++++- 6 files changed, 158 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run-accounting.ts b/packages/opencode/src/cli/cmd/run-accounting.ts index 33a501347a..ecd915bcb3 100644 --- a/packages/opencode/src/cli/cmd/run-accounting.ts +++ b/packages/opencode/src/cli/cmd/run-accounting.ts @@ -33,6 +33,41 @@ export type Termination = { // Timeout classification for why_harness_stopped="timeout" and retry decisions. const TIMEOUT_PATTERN = /\btimed?\s*out\b|\bETIMEDOUT\b|TimeoutError/i +/** Request ownership from the ordered message/request event stream. */ +export function createRequestOwnership() { + let activeRequestID: string | undefined + const users = new Map() + const assistants = new Map() + const seenRequests = new Set() + return { + get activeRequestID() { + return activeRequestID + }, + beginRequest(messageID: string) { + if (seenRequests.has(messageID)) return false + seenRequests.add(messageID) + users.set(messageID, messageID) + activeRequestID = messageID + return true + }, + onUserMessage(messageID: string, parentID?: string) { + // Internal rows have no parentID in their message schema. The prompt loop + // reports their origin separately, before creating the next assistant. + // A persisted real request always retains its own boundary. + if (seenRequests.has(messageID)) return + if (parentID !== undefined) users.set(messageID, users.get(parentID)) + else if (!users.has(messageID)) users.set(messageID, activeRequestID) + }, + onAssistantMessage(info: { id: string; parentID: string }) { + if (assistants.has(info.id)) return + assistants.set(info.id, users.get(info.parentID)) + }, + ownerOf(messageID: string) { + return assistants.get(messageID) + }, + } +} + /** Holds only overflow errors whose trace status depends on later recovery. */ export function createRecoverableOverflowTraceErrors() { let pending: string[] = [] diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 319266903d..7fc0c34a29 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -727,19 +727,13 @@ You are speaking to a non-technical business executive. Follow these rules stric // altimate_change start — turn accounting + dual-attribution // termination state for this run (see run-accounting.ts). const accounting = RunAccounting.create() - let activeRequestID: string | undefined - const assistantRequests = new Map() + const requestOwnership = RunAccounting.createRequestOwnership() // A queued request must not revoke the in-flight echo owned by an earlier request. const committedRequests = new Set() - const userRequests = new Map() - const seenRequests = new Set() const beginRequest = (messageID: string) => { // The persisted request boundary includes requests from other attached clients. // Retries retain their ID; internal synthetic/compaction rows do not emit this event. - if (seenRequests.has(messageID)) return - seenRequests.add(messageID) - userRequests.set(messageID, messageID) - activeRequestID = messageID + if (!requestOwnership.beginRequest(messageID)) return accounting.resetCommittedAnswer() } // altimate_change end @@ -968,14 +962,24 @@ You are speaking to a non-technical business executive. Follow these rules stric if (options?.signal?.aborted) break await runTracing?.observe(event) if (options?.signal?.aborted) break - // altimate_change start - other attached clients can submit a new request within this stream - const requestEvent = event as { type: string; properties: { sessionID?: string; messageID?: string } } + // altimate_change start — preserve request boundaries and internal continuation ownership on attached streams + const requestEvent = event as { + type: string + properties: { sessionID?: string; messageID?: string; parentID?: string } + } if ( requestEvent.type === "session.request.started" && requestEvent.properties.sessionID === sessionID && requestEvent.properties.messageID ) beginRequest(requestEvent.properties.messageID) + if ( + requestEvent.type === "session.request.continued" && + requestEvent.properties.sessionID === sessionID && + requestEvent.properties.messageID && + requestEvent.properties.parentID + ) + requestOwnership.onUserMessage(requestEvent.properties.messageID, requestEvent.properties.parentID) // altimate_change end // altimate_change start — shared SDKs omit fork-only run budget events const budgetEvent = event as { type: string; properties: { sessionID?: string; timestamp?: number } } @@ -997,12 +1001,11 @@ You are speaking to a non-technical business executive. Follow these rules stric if ( event.type === "message.updated" && event.properties.info.role === "user" && - event.properties.info.sessionID === sessionID && - !userRequests.has(event.properties.info.id) + event.properties.info.sessionID === sessionID ) { - // Compaction-created parent rows inherit the current request; a real - // request's subsequent persisted boundary replaces this with its own ID. - userRequests.set(event.properties.info.id, activeRequestID) + // This provisional owner is replaced by the persisted request boundary + // or the internal continuation's explicit origin before its assistant starts. + requestOwnership.onUserMessage(event.properties.info.id) } if ( event.type === "message.updated" && @@ -1012,9 +1015,7 @@ You are speaking to a non-technical business executive. Follow these rules stric accounting.onAssistantMessage(event.properties.info) // Keep the request that owned the assistant's creation, even if an older // tool finishes after another attached client persists a new request. - if (!assistantRequests.has(event.properties.info.id)) { - assistantRequests.set(event.properties.info.id, userRequests.get(event.properties.info.parentID)) - } + requestOwnership.onAssistantMessage(event.properties.info) assistantStarted = true // Enrich only from this run's assistant, independently of presentation. const info = event.properties.info @@ -1056,12 +1057,12 @@ You are speaking to a non-technical business executive. Follow these rules stric part.type === "tool" && part.tool === "answer_commit" && (part.state.status === "completed" || part.state.status === "error") ) { - const requestID = assistantRequests.get(part.messageID) + const requestID = requestOwnership.ownerOf(part.messageID) if (requestID !== undefined) { // Success and failed retries affect only the request that owns this tool. if (part.state.status === "completed") committedRequests.add(requestID) else committedRequests.delete(requestID) - if (requestID === activeRequestID) accounting.onAnswerCommit(part.state.status) + if (requestID === requestOwnership.activeRequestID) accounting.onAnswerCommit(part.state.status) } } // altimate_change end @@ -1117,7 +1118,7 @@ You are speaking to a non-technical business executive. Follow these rules stric // the owning message's agent is resolved via the message.updated lookup // above, so compacting models are not differentially charged turns. const counted = accounting.onStepStart(part.messageID) - const requestID = assistantRequests.get(part.messageID) + const requestID = requestOwnership.ownerOf(part.messageID) const committedAnswer = requestID !== undefined && committedRequests.has(requestID) // altimate_change start — the time-budget abort already owns the interrupted work phase if ( diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 0fe610ef99..34ce85348d 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1302,9 +1302,10 @@ export namespace MessageV2 { }) // altimate_change start — structured policy decisions override overflow text, while explicit overflow codes win // Only the provider's own code decides overflow; a nested diagnostic code must not mask a policy denial. + // An explicit policy code also beats a 413 status: a policy denial must stay fatal, never a recoverable overflow. const overflowCode = isOverflowCode(providerErrorCode(e.responseBody)) const policyCode = echoPolicyCode(e.responseBody) - if (policyCode && e.statusCode !== 413 && !overflowCode) { + if (policyCode && !overflowCode) { return new MessageV2.APIError( { message: parsed.message, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 48cfa4f933..4b928eccbb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -138,6 +138,10 @@ const RequestStarted = BusEvent.define( "session.request.started", z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), ) +const RequestContinued = BusEvent.define( + "session.request.continued", + z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, parentID: MessageID.zod }), +) // altimate_change end export namespace SessionPrompt { @@ -675,7 +679,7 @@ export namespace SessionPrompt { // altimate_change start — client trace: the turns this generation runs. Released when it ends // (disposed before the generation's own cleanup, so no newer generation exists yet). const tracedTurns = new Set() - let currentTurn: string | undefined + let currentTurn: MessageID | undefined using _traceGeneration = defer(() => TraceContext.release(sessionID, tracedTurns)) // altimate_change end @@ -1097,8 +1101,10 @@ export namespace SessionPrompt { const isCompaction = msgs.some( (msg) => msg.info.id === stepUser.id && msg.parts.some((part) => part.type === "compaction"), ) - if (isCompaction && currentTurn && !TraceContext.forMessage(stepUser.id)) { - TraceContext.inherit(stepUser.id, currentTurn) + if (isCompaction && currentTurn) { + // Request ownership must survive even when no client trace is active. + await Bus.publish(RequestContinued, { sessionID, messageID: stepUser.id, parentID: currentTurn }) + if (!TraceContext.forMessage(stepUser.id)) TraceContext.inherit(stepUser.id, currentTurn) } else if (!isCompaction) { currentTurn = stepUser.id } @@ -1353,6 +1359,7 @@ export namespace SessionPrompt { await Session.updateMessage(summaryUserMsg) // altimate_change start — this loop-written message continues the step's turn (client trace) TraceContext.inherit(summaryUserMsg.id, lastUser.id) + await Bus.publish(RequestContinued, { sessionID, messageID: summaryUserMsg.id, parentID: lastUser.id }) // altimate_change end await Session.updatePart({ id: PartID.ascending(), @@ -1375,6 +1382,9 @@ export namespace SessionPrompt { return false }) // altimate_change end + // altimate_change start — publish the recorded continuation origin before its next assistant + let continuationID: MessageID | undefined + // altimate_change end const result = await SessionCompaction.process({ messages: msgs, parentID: lastUser.id, @@ -1385,6 +1395,7 @@ export namespace SessionPrompt { // altimate_change start - distinguish automatic continuations from user-authored synthetic parts onContinuation: (messageID) => { compactionContinuations.add(messageID) + continuationID = messageID }, recoverCommittedAnswer: committedAnswer !== undefined, // altimate_change end @@ -1398,6 +1409,11 @@ export namespace SessionPrompt { learnDelivery: learnDelivery ? lessonState : undefined, // altimate_change end }) + // altimate_change start — attached run clients cannot read the compactor's in-memory origin + if (continuationID) { + await Bus.publish(RequestContinued, { sessionID, messageID: continuationID, parentID: lastUser.id }) + } + // altimate_change end // altimate_change start — treat any non-"continue" result as stop: an // undefined/unknown result must never fall through to `continue`, which // re-enters compaction on the same unresolved marker and busy-loops. @@ -2344,6 +2360,7 @@ export namespace SessionPrompt { } as MessageV2.Info) // This loop-written message continues the step's turn (client trace). TraceContext.inherit(syntheticMessageID, lastUser.id) + await Bus.publish(RequestContinued, { sessionID, messageID: syntheticMessageID, parentID: lastUser.id }) // Append the validator body as a text part on the new user message. await Session.updatePart({ diff --git a/packages/opencode/test/cli/run/budget-finalization.test.ts b/packages/opencode/test/cli/run/budget-finalization.test.ts index d622fa83ce..fe3f3dc97c 100644 --- a/packages/opencode/test/cli/run/budget-finalization.test.ts +++ b/packages/opencode/test/cli/run/budget-finalization.test.ts @@ -558,6 +558,70 @@ describe("opencode run: budget finalization subprocess", () => { }), 90_000, ) + // altimate_change start — compaction continuations keep their originating request's echo allowance + // These exercise the same ownership helper as run.ts without requiring a listening socket. + for (const queued of ["none", "before the marker", "during the summary"] as const) { + test(`proactive compaction preserves committed echo ownership with a queued request: ${queued}`, () => { + const ownership = RunAccounting.createRequestOwnership() + const accounting = RunAccounting.create() + const committedRequests = new Set() + ownership.beginRequest("original") + ownership.onAssistantMessage({ id: "commit", parentID: "original" }) + accounting.onStepStart("commit") + committedRequests.add(ownership.ownerOf("commit")!) + const queue = () => { + ownership.onUserMessage("queued") + ownership.beginRequest("queued") + } + if (queued === "before the marker") queue() + ownership.onUserMessage("marker") + ownership.onUserMessage("marker", "original") + ownership.onAssistantMessage({ id: "summary", parentID: "marker" }) + accounting.onAssistantMessage({ id: "summary", summary: true }) + expect(accounting.onStepStart("summary")).toBe(false) + if (queued === "during the summary") queue() + + // SessionCompaction persists this synthetic user before reporting its origin. + ownership.onUserMessage("continuation") + ownership.onUserMessage("continuation", "marker") + // Another compaction must preserve the original ID, not the intervening user ID. + ownership.onUserMessage("second-marker", "continuation") + ownership.onUserMessage("second-continuation", "second-marker") + ownership.onAssistantMessage({ id: "echo", parentID: "second-continuation" }) + expect(ownership.ownerOf("echo")).toBe("original") + expect(accounting.onStepStart("echo")).toBe(true) + expect(accounting.turnCount).toBe(2) + expect(committedRequests.has(ownership.ownerOf("echo")!)).toBe(true) + + if (queued !== "none") { + ownership.onAssistantMessage({ id: "queued-work", parentID: "queued" }) + // Late updates from the old request cannot transfer its exemption to the new one. + ownership.onAssistantMessage({ id: "commit", parentID: "original" }) + expect(ownership.ownerOf("queued-work")).toBe("queued") + expect(committedRequests.has(ownership.ownerOf("queued-work")!)).toBe(false) + } + }) + } + + test("an ordinary request compacted after a committed echo keeps its own over-budget ownership", () => { + const ownership = RunAccounting.createRequestOwnership() + ownership.beginRequest("committed") + ownership.onAssistantMessage({ id: "old-echo", parentID: "committed" }) + ownership.onUserMessage("ordinary") + ownership.beginRequest("ordinary") + ownership.onUserMessage("ordinary", "committed") + // The new request can compact before creating its first assistant. + ownership.onUserMessage("marker", "ordinary") + ownership.onUserMessage("continuation", "marker") + ownership.onAssistantMessage({ id: "ordinary-work", parentID: "continuation" }) + expect(ownership.ownerOf("ordinary-work")).toBe("ordinary") + expect(ownership.ownerOf("ordinary-work")).not.toBe(ownership.ownerOf("old-echo")) + // A continuation with an unobserved parent cannot borrow the active request. + ownership.onUserMessage("unknown", "unobserved") + ownership.onAssistantMessage({ id: "unknown-work", parentID: "unknown" }) + expect(ownership.ownerOf("unknown-work")).toBeUndefined() + }) + // altimate_change end cliIt.concurrent( "answer_commit on --max-turns 1 publishes the committed output on its extra echo turn", ({ llm, opencode }) => diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 4eb5b79828..28b8dabe79 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1630,7 +1630,7 @@ describe("session.message-v2.fromError", () => { }) } - test("HTTP 413 remains overflow despite a structured policy code", () => { + test("an explicit policy code on HTTP 413 stays a policy error, not an overflow", () => { const result = MessageV2.fromError(new APICallError({ message: "Request failed", url: "https://example.com", @@ -1640,6 +1640,20 @@ describe("session.message-v2.fromError", () => { responseBody: JSON.stringify({ error: { code: "policy_violation" } }), }), { providerID }) + expect(result.name).toBe("APIError") + expect(result).toHaveProperty("data.metadata.code", "policy_violation") + }) + + test("HTTP 413 without a policy code is still an overflow", () => { + const result = MessageV2.fromError(new APICallError({ + message: "Request failed", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 413, + isRetryable: false, + responseBody: JSON.stringify({ error: { message: "request entity too large" } }), + }), { providerID }) + expect(result.name).toBe("ContextOverflowError") }) From b9aa37a6c022539604b01e5d17c879651f85cc3b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Wed, 14 Oct 2026 11:27:34 -0700 Subject: [PATCH 38/39] refactor: export `isEchoTransportError` at module scope The predicate moves out of the `MessageV2` namespace to a flat top-level export, following the package's export convention. Callers in `prompt.ts`, `processor.ts` and the answer-commit tests import it directly. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/session/message-v2.ts | 37 ++++++++++--------- packages/opencode/src/session/processor.ts | 6 ++- packages/opencode/src/session/prompt.ts | 6 ++- .../opencode/test/agent/answer-commit.test.ts | 26 ++++++------- 4 files changed, 40 insertions(+), 35 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 34ce85348d..7fc1f336a1 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -592,24 +592,6 @@ export namespace MessageV2 { }) export type Assistant = z.infer - // altimate_change start — recover committed echoes only from transient transport failures - export function isEchoTransportError(error: Assistant["error"]): boolean { - if (error?.name !== "APIError") return false - const { statusCode, responseBody, metadata, isRetryable } = error.data - // Provider decisions can be wrapped by a gateway with a transient HTTP status. - // Inspect structured codes, never prose in a message or response body. - if (echoPolicyCode(responseBody) || isEchoPolicyCode(metadata?.code)) return false - if (statusCode !== undefined) - return (statusCode >= 500 && statusCode < 600) || statusCode === 429 || statusCode === 408 - const code = metadata?.code - return ( - (code !== undefined && - (echoNetworkCodes.has(code) || - ["ZlibError", "ProviderHeaderTimeoutError", "ProviderStreamIdleTimeoutError"].includes(code))) || - (isRetryable && metadata?.url !== undefined && !responseBody) - ) - } - // altimate_change end export const Info = z.discriminatedUnion("role", [User, Assistant]).meta({ ref: "Message", }) @@ -1383,3 +1365,22 @@ export namespace MessageV2 { } } } + +// altimate_change start — recover committed echoes only from transient transport failures +export function isEchoTransportError(error: MessageV2.Assistant["error"]): boolean { + if (error?.name !== "APIError") return false + const { statusCode, responseBody, metadata, isRetryable } = error.data + // Provider decisions can be wrapped by a gateway with a transient HTTP status. + // Inspect structured codes, never prose in a message or response body. + if (echoPolicyCode(responseBody) || isEchoPolicyCode(metadata?.code)) return false + if (statusCode !== undefined) + return (statusCode >= 500 && statusCode < 600) || statusCode === 429 || statusCode === 408 + const code = metadata?.code + return ( + (code !== undefined && + (echoNetworkCodes.has(code) || + ["ZlibError", "ProviderHeaderTimeoutError", "ProviderStreamIdleTimeoutError"].includes(code))) || + (isRetryable && metadata?.url !== undefined && !responseBody) + ) +} +// altimate_change end diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 4f60e2ae45..065ffd3bd9 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,6 @@ -import { MessageV2 } from "./message-v2" +// altimate_change start — module-scope echo transport predicate +import { MessageV2, isEchoTransportError } from "./message-v2" +// altimate_change end import { Log } from "@/util/log" // altimate_change start — client trace propagation import { TraceContext } from "@/altimate/observability/trace-context" @@ -1274,7 +1276,7 @@ export namespace SessionProcessor { (deferText || (input.recoverCommittedAnswer && input.assistantMessage.summary)) && !input.abort.aborted && input.assistantMessage.finish !== "content-filter" && - (MessageV2.isEchoTransportError(error) || MessageV2.ContextOverflowError.isInstance(error)) + (isEchoTransportError(error) || MessageV2.ContextOverflowError.isInstance(error)) ) { input.assistantMessage.metadata = { ...input.assistantMessage.metadata, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4b928eccbb..967f2e196f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -6,7 +6,9 @@ import fs from "fs/promises" import z from "zod" import { Filesystem } from "../util/filesystem" import { SessionID, MessageID, PartID } from "./schema" -import { MessageV2 } from "./message-v2" +// altimate_change start — module-scope echo transport predicate +import { MessageV2, isEchoTransportError } from "./message-v2" +// altimate_change end import { Log } from "../util/log" import { SessionRevert } from "./revert" import { Session } from "." @@ -932,7 +934,7 @@ export namespace SessionPrompt { cause?.name === "ContentFilterError" || (cause && ["APIError", "UnknownError", "ProviderAuthError"].includes(cause.name) && - !MessageV2.isEchoTransportError(cause)), + !isEchoTransportError(cause)), ) ) return false diff --git a/packages/opencode/test/agent/answer-commit.test.ts b/packages/opencode/test/agent/answer-commit.test.ts index 32166e9a5f..0aa2987bf1 100644 --- a/packages/opencode/test/agent/answer-commit.test.ts +++ b/packages/opencode/test/agent/answer-commit.test.ts @@ -21,7 +21,7 @@ import { SessionPrompt } from "../../src/session/prompt" import { SessionRoutes } from "../../src/server/routes/session" import { SessionTermination } from "../../src/session/termination" import { SessionCompaction } from "../../src/session/compaction" -import { MessageV2 } from "../../src/session/message-v2" +import { MessageV2, isEchoTransportError } from "../../src/session/message-v2" import { SessionSummary } from "../../src/session/summary" // altimate_change start — error provenance fixtures for committed-answer echoes import { APICallError } from "ai" @@ -1558,13 +1558,13 @@ describe("committed-answer echo error classification", () => { for (const statusCode of [408, 429, 500, 503, 599]) { test(`HTTP ${statusCode} is a recoverable echo transport failure`, () => { const error = new MessageV2.APIError({ message: "Request failed", statusCode, isRetryable: false }).toObject() - expect(MessageV2.isEchoTransportError(error)).toBe(true) + expect(isEchoTransportError(error)).toBe(true) }) } for (const statusCode of [400, 401, 403, 404]) { test(`HTTP ${statusCode} is never a recoverable echo transport failure`, () => { const error = new MessageV2.APIError({ message: "Request failed", statusCode, isRetryable: true }).toObject() - expect(MessageV2.isEchoTransportError(error)).toBe(false) + expect(isEchoTransportError(error)).toBe(false) }) } for (const code of [ @@ -1578,7 +1578,7 @@ describe("committed-answer echo error classification", () => { isRetryable: true, responseBody: JSON.stringify({ error: { code } }), }).toObject() - expect(MessageV2.isEchoTransportError(error)).toBe(false) + expect(isEchoTransportError(error)).toBe(false) }) } test("a policy code beyond the response-body limit survives API error normalization", () => { @@ -1591,7 +1591,7 @@ describe("committed-answer echo error classification", () => { responseBody: JSON.stringify({ details: "x".repeat(5000), error: { code: "policy_violation" } }), }), { providerID: promptModel.providerID }) expect(error.name).toBe("APIError") - expect(MessageV2.isEchoTransportError(error)).toBe(false) + expect(isEchoTransportError(error)).toBe(false) }) test("a structured policy code never overrides an explicit context overflow code", () => { const error = MessageV2.fromError(new APICallError({ @@ -1606,7 +1606,7 @@ describe("committed-answer echo error classification", () => { }), }), { providerID: promptModel.providerID }) expect(error.name).toBe("ContextOverflowError") - expect(MessageV2.isEchoTransportError(error)).toBe(false) + expect(isEchoTransportError(error)).toBe(false) }) test("a 503 orchestration guardrail code survives normalization and vetoes echo recovery", () => { const error = MessageV2.fromError(new APICallError({ @@ -1618,7 +1618,7 @@ describe("committed-answer echo error classification", () => { responseBody: JSON.stringify({ orchestrationError: { code: "guardrail_intervened" } }), }), { providerID: promptModel.providerID }) expect(error.name).toBe("APIError") - expect(MessageV2.isEchoTransportError(error)).toBe(false) + expect(isEchoTransportError(error)).toBe(false) }) test("a 413 content_length_exceeded code remains a context overflow", () => { const error = MessageV2.fromError(new APICallError({ @@ -1630,7 +1630,7 @@ describe("committed-answer echo error classification", () => { responseBody: JSON.stringify({ error: { code: "content_length_exceeded" } }), }), { providerID: promptModel.providerID }) expect(error.name).toBe("ContextOverflowError") - expect(MessageV2.isEchoTransportError(error)).toBe(false) + expect(isEchoTransportError(error)).toBe(false) }) test("a wrapped socket reset without a status remains recoverable even when the SDK disables retries", () => { const error = MessageV2.fromError(new APICallError({ @@ -1641,7 +1641,7 @@ describe("committed-answer echo error classification", () => { cause: Object.assign(new Error("socket reset"), { code: "ECONNRESET" }), }), { providerID: promptModel.providerID }) expect(error.name).toBe("APIError") - expect(MessageV2.isEchoTransportError(error)).toBe(true) + expect(isEchoTransportError(error)).toBe(true) }) test("statusless socket and watchdog failures are recoverable but local failures and auth are not", () => { for (const code of ["ECONNRESET", "ETIMEDOUT", "ProviderStreamIdleTimeoutError"]) { @@ -1650,15 +1650,15 @@ describe("committed-answer echo error classification", () => { isRetryable: false, metadata: { code }, }).toObject() - expect(MessageV2.isEchoTransportError(error)).toBe(true) + expect(isEchoTransportError(error)).toBe(true) } - expect(MessageV2.isEchoTransportError( + expect(isEchoTransportError( new MessageV2.APIError({ message: "Unclassified failure", isRetryable: false }).toObject(), )).toBe(false) - expect(MessageV2.isEchoTransportError( + expect(isEchoTransportError( MessageV2.fromError(new Error("Snapshot tracking failed"), { providerID: promptModel.providerID }), )).toBe(false) - expect(MessageV2.isEchoTransportError( + expect(isEchoTransportError( new MessageV2.AuthError({ message: "Authentication failed", providerID: promptModel.providerID }).toObject(), )).toBe(false) }) From 6fffb8bd3824f4128351a8c4e5811f274979bdaa Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Wed, 14 Oct 2026 11:42:00 -0700 Subject: [PATCH 39/39] test: import `isEchoTransportError` directly in the message tests Co-Authored-By: Claude Opus 5.5 --- packages/opencode/test/session/message-v2.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 28b8dabe79..802e1aac51 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { APICallError } from "ai" -import { MessageV2 } from "../../src/session/message-v2" +import { MessageV2, isEchoTransportError } from "../../src/session/message-v2" import { SessionRetry } from "../../src/session/retry" import { ProviderError } from "../../src/provider/error" import { ProviderTransform } from "@/provider/transform" @@ -1548,7 +1548,7 @@ describe("session.message-v2.fromError", () => { expect(result.name).toBe("APIError") expect((result.data as MessageV2.APIError["data"]).metadata?.code).toBe(code) - expect(MessageV2.isEchoTransportError(result)).toBe(false) + expect(isEchoTransportError(result)).toBe(false) }) } @@ -1567,7 +1567,7 @@ describe("session.message-v2.fromError", () => { expect(result.name).toBe("APIError") expect((result.data as MessageV2.APIError["data"]).metadata?.code).toBe("guardrail_intervened") - expect(MessageV2.isEchoTransportError(result)).toBe(false) + expect(isEchoTransportError(result)).toBe(false) }) } @@ -1607,7 +1607,7 @@ describe("session.message-v2.fromError", () => { }), { providerID }) expect(result.name).toBe("APIError") - expect(MessageV2.isEchoTransportError(result)).toBe(false) + expect(isEchoTransportError(result)).toBe(false) expect((result as any).data.metadata.code).toBe("policy_violation") }) }