mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-13 07:47:46 +08:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user