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.
This commit is contained in:
maphew
2026-09-27 03:58:16 -07:00
committed by matt wilkie
parent 7d977bce99
commit 1f093ffed0
4 changed files with 343 additions and 1 deletions
@@ -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.
@@ -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<ZodIssue | ZodIssue[]>
}
export namespace KiloMessageDiagnostics {
export type MessageShape = {
index: number
role: string
parts: Array<Record<string, unknown>>
providerOptions?: string[]
}
export type ToolPairing = {
totalToolCalls: number
matchedResultIds: string[]
unmatchedCallIds: string[]
orphanResultIds: string[]
}
export function schemaIssues(err: unknown): string[] {
const seen = new Set<unknown>()
const walk = (e: unknown): string[] => {
if (!e || typeof e !== "object" || seen.has(e)) return []
seen.add(e)
const obj = e as Record<string, unknown>
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<string>()
const resultIds = new Set<string>()
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<typeof describe> | 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
}
}
+9 -1
View File
@@ -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)),
)
}),
@@ -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"])
})
})