From 1f093ffed0ae99d91bbbc949eac6d4f180d39cfc Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:57:09 +0000 Subject: [PATCH] fix(cli): log structural diagnostics on ModelMessage[] schema failure The AI SDK rejects an assembled request with a bare 'The messages do not match the ModelMessage[] schema.' error; the ZodError cause with the exact schema paths was never surfaced, so root-causing the failure (issue #13185) was impossible. Thread the prepared messages to the AI SDK stream adapter and, on failure, log a redacted structural diagnostic: schema issue paths with expected/ received types, per-message part sequences, tool-call/result pairing, and provider option key names. No content values are logged. Kilo-owned diagnostics live in src/kilocode/session/message-diagnostics.ts; the shared llm.ts change is kilocode_change-marked. --- .changeset/kilocode-message-diagnostics.md | 5 + .../kilocode/session/message-diagnostics.ts | 146 ++++++++++++++ packages/opencode/src/session/llm.ts | 10 +- .../test/kilocode/message-diagnostics.test.ts | 183 ++++++++++++++++++ 4 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 .changeset/kilocode-message-diagnostics.md create mode 100644 packages/opencode/src/kilocode/session/message-diagnostics.ts create mode 100644 packages/opencode/test/kilocode/message-diagnostics.test.ts diff --git a/.changeset/kilocode-message-diagnostics.md b/.changeset/kilocode-message-diagnostics.md new file mode 100644 index 00000000000..63029fb14e8 --- /dev/null +++ b/.changeset/kilocode-message-diagnostics.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Log structural diagnostics (message part sequences, tool-call/result pairing, schema issue paths) when the AI SDK rejects an assembled request, instead of failing with a bare `ModelMessage[] schema` error. diff --git a/packages/opencode/src/kilocode/session/message-diagnostics.ts b/packages/opencode/src/kilocode/session/message-diagnostics.ts new file mode 100644 index 00000000000..995f603fd63 --- /dev/null +++ b/packages/opencode/src/kilocode/session/message-diagnostics.ts @@ -0,0 +1,146 @@ +import type { ModelMessage } from "ai" +import { Log } from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "kilocode.message.diagnostics" }) + +type ZodIssue = { + path?: (string | number)[] + message?: string + code?: string + expected?: unknown + received?: unknown + errors?: Array +} + +export namespace KiloMessageDiagnostics { + export type MessageShape = { + index: number + role: string + parts: Array> + providerOptions?: string[] + } + + export type ToolPairing = { + totalToolCalls: number + matchedResultIds: string[] + unmatchedCallIds: string[] + orphanResultIds: string[] + } + + export function schemaIssues(err: unknown): string[] { + const seen = new Set() + const walk = (e: unknown): string[] => { + if (!e || typeof e !== "object" || seen.has(e)) return [] + seen.add(e) + const obj = e as Record + if (Array.isArray(obj.issues)) { + return flattenIssues(obj.issues as ZodIssue[]) + } + const cause = obj.cause + if (cause) return walk(cause) + return [] + } + return walk(err) + } + + function flattenIssues(issues: ZodIssue[]): string[] { + const result: string[] = [] + for (const issue of issues) { + const path = issue.path?.join(".") ?? "?" + const msg = issue.message ?? "" + const code = issue.code ?? "" + const expected = short(issue.expected) + const received = short(issue.received) + result.push(`${path}: ${msg} [${code}] expected=${expected} received=${received}`) + // zod/v4 unions nest their member failures under `errors`; recurse so the + // deep schema path (e.g. `content.0.text`) is not lost behind `invalid_union` + if (issue.errors) { + for (const sub of issue.errors) { + const nested = Array.isArray(sub) ? sub : [sub] + for (const inner of nested) { + result.push( + ...flattenIssues([{ ...inner, path: [...(issue.path ?? []), ...(inner.path ?? [])] }]), + ) + } + } + } + } + return result + } + + function short(value: unknown): string { + if (value === undefined) return "" + const str = typeof value === "string" ? value : safeJSON(value) + return str.length > 80 ? `${str.slice(0, 80)}…` : str + } + + // Zod issue `expected`/`received` are schema type names in practice, but + // custom issues can carry arbitrary values. Diagnostics must never throw on + // the failure path, so circular/unserializable values degrade to a placeholder. + function safeJSON(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value) + } catch { + return "[unserializable]" + } + } + + export function messageShape(msgs: readonly ModelMessage[]): MessageShape[] { + return msgs.map((msg, i) => { + const parts = Array.isArray(msg.content) ? msg.content : [] + return { + index: i, + role: msg.role, + parts: parts.map((p) => { + if (p.type === "tool-call") return { type: "tool-call", toolCallId: p.toolCallId, toolName: p.toolName } + if (p.type === "tool-result") return { type: "tool-result", toolCallId: p.toolCallId, toolName: p.toolName } + if (p.type === "reasoning") return { type: "reasoning" } + if (p.type === "text") return { type: "text" } + if (p.type === "file") return { type: "file", mediaType: p.mediaType } + return { type: p.type } + }), + providerOptions: msg.providerOptions ? Object.keys(msg.providerOptions) : undefined, + } + }) + } + + export function toolPairing(msgs: readonly ModelMessage[]): ToolPairing { + const callIds = new Set() + const resultIds = new Set() + for (const msg of msgs) { + if (!Array.isArray(msg.content)) continue + for (const part of msg.content) { + if (part.type === "tool-call") callIds.add(part.toolCallId) + if (part.type === "tool-result") resultIds.add(part.toolCallId) + } + } + const matched = [...callIds].filter((id) => resultIds.has(id)) + const unmatched = [...callIds].filter((id) => !resultIds.has(id)) + const orphan = [...resultIds].filter((id) => !callIds.has(id)) + return { + totalToolCalls: callIds.size, + matchedResultIds: matched, + unmatchedCallIds: unmatched, + orphanResultIds: orphan, + } + } + + export function describe(err: unknown, msgs: readonly ModelMessage[]) { + return { + issues: schemaIssues(err), + messages: messageShape(msgs), + pairing: toolPairing(msgs), + } + } + + export function reportModelMessageError( + err: unknown, + msgs: readonly ModelMessage[], + ): ReturnType | undefined { + const mismatch = err instanceof Error && err.message.includes("do not match the ModelMessage[] schema") + if (!mismatch) return undefined + const diagnostic = describe(err, msgs) + log.error("ModelMessage[] schema validation failed", diagnostic) + return diagnostic + } +} diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index e90d60f98ff..e5711aa5969 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -30,6 +30,7 @@ import { KiloSession } from "@/kilocode/session" import { KiloLLM } from "@/kilocode/session/llm" import { KiloSessionOverflow } from "@/kilocode/session/overflow" import { KiloToolSchema } from "@/kilocode/session/tool-schema" +import { KiloMessageDiagnostics } from "@/kilocode/session/message-diagnostics" import { SessionExport } from "@/kilocode/session-export" import { getActiveOrg } from "@/kilocode/session-export/eligibility" import { normalizeUsageForExport, observeFullStreamForExport } from "@/kilocode/session-export/llm" @@ -436,7 +437,7 @@ const live: Layer.Layer< }) // kilocode_change end // kilocode_change start - capture eligible session export request completion off the stream path - if (!exportable) return { type: "ai-sdk" as const, result } + if (!exportable) return { type: "ai-sdk" as const, result, messages: prepared.messages } return { type: "ai-sdk" as const, result: { @@ -450,6 +451,7 @@ const live: Layer.Layer< retries: input.retries ?? 0, }), }, + messages: prepared.messages, } // kilocode_change end }) @@ -474,6 +476,12 @@ const live: Layer.Layer< e instanceof Error ? e : new Error(String(e)), ).pipe( Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)), + // kilocode_change start - emit structural diagnostics when the AI SDK + // rejects the assembled ModelMessage[] before dispatch (#13185) + Stream.tapError((e) => + Effect.sync(() => KiloMessageDiagnostics.reportModelMessageError(e, result.messages)), + ), + // kilocode_change end Stream.flatMap((events) => Stream.fromIterable(events)), ) }), diff --git a/packages/opencode/test/kilocode/message-diagnostics.test.ts b/packages/opencode/test/kilocode/message-diagnostics.test.ts new file mode 100644 index 00000000000..366f938170b --- /dev/null +++ b/packages/opencode/test/kilocode/message-diagnostics.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test" +import { z } from "zod/v4" +import type { ModelMessage } from "ai" +import { KiloMessageDiagnostics } from "@/kilocode/session/message-diagnostics" +import { LLMAISDK } from "@/session/llm/ai-sdk" +import { Effect, Exit } from "effect" + +const messages: ModelMessage[] = [ + { + role: "user", + content: "summarize the repo", + }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "on it" }, + { type: "tool-call", toolCallId: "call-1", toolName: "grep", input: { pattern: "x" } }, + ], + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-1", toolName: "grep", output: { type: "text", value: "hit" } }], + }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-2", toolName: "write", input: { path: "a.ts" } }], + }, +] + +describe("messageShape", () => { + test("summarizes roles and part sequences without content", () => { + const shape = KiloMessageDiagnostics.messageShape(messages) + expect(shape).toEqual([ + { index: 0, role: "user", parts: [], providerOptions: undefined }, + { + index: 1, + role: "assistant", + parts: [ + { type: "reasoning" }, + { type: "text" }, + { type: "tool-call", toolCallId: "call-1", toolName: "grep" }, + ], + providerOptions: undefined, + }, + { + index: 2, + role: "tool", + parts: [{ type: "tool-result", toolCallId: "call-1", toolName: "grep" }], + providerOptions: undefined, + }, + { + index: 3, + role: "assistant", + parts: [{ type: "tool-call", toolCallId: "call-2", toolName: "write" }], + providerOptions: undefined, + }, + ]) + }) + + test("lists provider option keys, not values", () => { + const shape = KiloMessageDiagnostics.messageShape([ + { + role: "assistant", + content: [{ type: "text", text: "hi" }], + providerOptions: { cacheControl: { type: "ephemeral" } }, + }, + ]) + expect(shape[0]?.providerOptions).toEqual(["cacheControl"]) + }) +}) + +describe("toolPairing", () => { + test("flags unmatched calls and orphan results", () => { + const pairing = KiloMessageDiagnostics.toolPairing(messages) + expect(pairing.totalToolCalls).toBe(2) + expect(pairing.matchedResultIds).toEqual(["call-1"]) + expect(pairing.unmatchedCallIds).toEqual(["call-2"]) + expect(pairing.orphanResultIds).toEqual([]) + }) + + test("flags orphan results without a matching call", () => { + const pairing = KiloMessageDiagnostics.toolPairing([ + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "ghost", toolName: "read", output: { type: "text", value: "x" } }], + }, + ]) + expect(pairing.totalToolCalls).toBe(0) + expect(pairing.orphanResultIds).toEqual(["ghost"]) + }) +}) + +describe("schemaIssues", () => { + test("extracts issue paths from a ZodError cause chain", () => { + const schema = z.object({ + role: z.literal("assistant"), + content: z.array( + z.object({ + type: z.literal("text"), + text: z.string(), + }), + ), + }) + const parsed = schema.safeParse({ role: "assistant", content: [{ type: "text", text: 42 }] }) + expect(parsed.success).toBe(false) + if (parsed.success) return + + const error = new Error("Invalid prompt: The messages do not match the ModelMessage[] schema.") + error.cause = parsed.error + const issues = KiloMessageDiagnostics.schemaIssues(error) + expect(issues.length).toBeGreaterThan(0) + expect(issues[0]).toContain("content.0.text") + }) + + test("recurses into union-nested errors to keep deep paths", () => { + // The AI SDK validates against a union of message shapes, so zod/v4 reports + // `invalid_union` with member failures nested under `errors`. The deep path + // must survive the recursion. + const schema = z.union([ + z.object({ role: z.literal("user"), content: z.string() }), + z.object({ + role: z.literal("assistant"), + content: z.array( + z.object({ + type: z.literal("text"), + text: z.string(), + }), + ), + }), + ]) + const parsed = schema.safeParse({ + role: "assistant", + content: [{ type: "tool-call", toolCallId: 42 }], + }) + expect(parsed.success).toBe(false) + if (parsed.success) return + + const error = new Error("Invalid prompt: The messages do not match the ModelMessage[] schema.") + error.cause = parsed.error + const issues = KiloMessageDiagnostics.schemaIssues(error) + expect(issues.some((issue) => issue.includes("content.0"))).toBe(true) + }) + + test("returns nothing for unrelated errors", () => { + expect(KiloMessageDiagnostics.schemaIssues(new Error("boom"))).toEqual([]) + expect(KiloMessageDiagnostics.schemaIssues(undefined)).toEqual([]) + }) +}) + +describe("reportModelMessageError", () => { + test("silently ignores non-schema errors", () => { + const out = KiloMessageDiagnostics.reportModelMessageError(new Error("rate limited"), messages) + expect(out).toBeUndefined() + }) + + test("returns the diagnostic for the ModelMessage[] schema mismatch", () => { + const error = new Error("Invalid prompt: The messages do not match the ModelMessage[] schema.") + const out = KiloMessageDiagnostics.reportModelMessageError(error, messages) + expect(out).toBeDefined() + expect(out?.messages.length).toBe(4) + }) +}) + +describe("LLMAISDK.toLLMEvents error-part mechanism", () => { + test("converts an error part into the stream failure tapError observes", async () => { + const state = LLMAISDK.adapterState() + const error = new Error("Invalid prompt: The messages do not match the ModelMessage[] schema.") + const effect = LLMAISDK.toLLMEvents(state, { type: "error", error }) + const exit = await Effect.runPromise(Effect.exit(effect)) + expect(Exit.isFailure(exit)).toBe(true) + }) +}) + +describe("describe", () => { + test("composes issues, shapes and pairing", () => { + const error = new Error("Invalid prompt: The messages do not match the ModelMessage[] schema.") + const out = KiloMessageDiagnostics.describe(error, messages) + expect(out.issues).toEqual([]) + expect(out.messages.length).toBe(4) + expect(out.pairing.unmatchedCallIds).toEqual(["call-2"]) + }) +})