Skip to content
Merged
44 changes: 41 additions & 3 deletions packages/opencode/src/inbox/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,44 @@ export const layer: Layer.Layer<
return 0
}

// Render BEFORE writing anything, and drop any row that renders blank.
// The drain is the one user-message producer that does NOT go through
// SessionPrompt.createUserMessage, so `hasSubstantiveContent` never sees
// it — a blank render would persist a user message whose only part is
// {type:"text",text:""}. The AI SDK's user branch filters empty text
// parts out with NO backfill, so that message reaches the provider as
// `content: []` and is rejected ("user messages must have non-empty
// content"). renderInboxRow already substitutes a placeholder for a
// blank body; this is the structural invariant that keeps the shape
// unreachable no matter what any future row type renders.
const rendered = rows.flatMap((row) => {
const text = renderInboxRow(row)
if (text.trim().length > 0) return [{ row, text }]
log.warn("inbox.drain: dropping row that rendered blank (would produce an empty user text part)", {
sessionID,
actorID,
rowID: row.id,
type: row.type,
})
return []
})

// Every row rendered blank: consume them (they carry no information and
// must not be re-drained forever) without writing a message at all. A
// zero-part user message would be skipped downstream anyway, so writing
// one is pure litter.
if (rendered.length === 0) {
yield* Effect.sync(() =>
Database.use((db) =>
db
.delete(InboxTable)
.where(inArray(InboxTable.id, rows.map((r) => r.id)))
.run(),
),
)
return 0
}

// Non-transactional crash window: updateMessage + updatePart commit
// before the inbox DELETE. A crash between them re-renders the same
// rows on next drain — LLM sees duplicated notifications. Tolerable;
Expand All @@ -265,14 +303,14 @@ export const layer: Layer.Layer<
agent: seed.agent,
model: seed.model,
})
for (const row of rows) {
for (const entry of rendered) {
yield* sessions.updatePart({
id: PartID.ascending(),
messageID: msgID,
sessionID,
type: "text" as const,
synthetic: true,
text: renderInboxRow(row),
text: entry.text,
})
}
yield* Effect.sync(() =>
Expand All @@ -284,7 +322,7 @@ export const layer: Layer.Layer<
),
)

return rows.length
return rendered.length
})

const impl = Service.of({ send, drain })
Expand Down
14 changes: 12 additions & 2 deletions packages/opencode/src/inbox/render.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import type { InboxRow } from "./inbox.sql"

// A blank body must fall back to the placeholder, not just a missing one.
// `?? placeholder` only catches null/undefined, but a blank body is stored as
// "" (or whitespace) — and for actor_notification the body is passed through
// RAW, so "" would become a user text part with text:"". The AI SDK's user
// branch filters empty text parts out with no backfill, leaving `content: []`
// and a provider 400 ("user messages must have non-empty content").
function blankTo(text: string | undefined, placeholder: string) {
return text !== undefined && text.trim().length > 0 ? text : placeholder
}

export function renderInboxRow(row: InboxRow): string {
if (row.type === "actor_notification") {
// Pre-rendered notification text — sender produced the full
// <actor-notification>...</actor-notification> wrapper.
const content = row.content as { text?: string }
return content.text ?? "(no notification body)"
return blankTo(content.text, "(no notification body)")
}
// Default: type === "text" or unknown — wrap as <inbox> element so
// the LLM can route by sender; the wrapper format mirrors the
Expand All @@ -15,7 +25,7 @@ export function renderInboxRow(row: InboxRow): string {
? `${row.sender_session_id}:${row.sender_actor_id ?? "?"}`
: "system"
const sentAt = new Date(row.created_at).toISOString()
return `<inbox from="${sender}" sent_at="${sentAt}">\n${content.text ?? "(empty)"}\n</inbox>`
return `<inbox from="${sender}" sent_at="${sentAt}">\n${blankTo(content.text, "(empty)")}\n</inbox>`
}

export function renderActorNotification(event: {
Expand Down
181 changes: 171 additions & 10 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,36 @@ function sdkKey(npm: string): string | undefined {
return undefined
}

// Providers that hard-reject an empty text/reasoning BLOCK inside an otherwise
// non-empty message ("text content blocks must be non-empty"). The AI SDK's own
// filter does not save us here: its user branch drops empty text parts, but its
// assistant branch KEEPS an empty text part that carries providerOptions, and it
// never inspects `reasoning` parts at all — so an empty reasoning block reaches
// the provider untouched for every npm package.
//
// The original list was `@ai-sdk/anthropic` + `@ai-sdk/amazon-bedrock` only,
// which missed the two other ways to reach the same Anthropic API:
// `@ai-sdk/google-vertex/anthropic` and Claude via `@openrouter/ai-sdk-provider`.
// Stripping an empty block is information-preserving for any provider, so the
// list errs on the side of including a provider rather than excluding one.
function stripsEmptyParts(model: Provider.Model): boolean {
return [
"@ai-sdk/anthropic",
"@ai-sdk/amazon-bedrock",
"@ai-sdk/google-vertex/anthropic",
"@openrouter/ai-sdk-provider",
"@ai-sdk/openai-compatible",
].includes(model.api.npm)
}

function normalizeMessages(
msgs: ModelMessage[],
model: Provider.Model,
_options: Record<string, unknown>,
): ModelMessage[] {
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") {
if (stripsEmptyParts(model)) {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
Expand Down Expand Up @@ -270,6 +292,92 @@ function supportsCacheMarkers(model: Provider.Model): boolean {
// not an assistant prefill.
const CONTINUATION_PROMPT = "Continue."

// Backfill text for a message whose content is structurally present but carries
// nothing a provider will accept. Same string as the continuation prompt: both
// mean "there is no new instruction here, keep going".
const EMPTY_CONTENT_PLACEHOLDER = CONTINUATION_PROMPT

// Mirrors the AI SDK's OWN user-content filter. `ai@6` builds the wire payload in
// `convertToLanguageModelMessage`, whose user branch is:
//
// content: message.content.map((part) => convertPartToLanguageModelPart(part, ...))
// .filter((part) => part.type !== "text" || part.text !== "")
//
// It runs AFTER every transform here and does NOT backfill, so a user message
// whose only text part is "" reaches the provider as `content: []` — exactly the
// shape observed in the live Bedrock 400 payload. Note the asymmetry: the SDK's
// assistant branch keeps an empty text part when it carries providerOptions
// (`|| part.providerOptions != null`); the user branch has no such escape, so
// even an empty text part holding a cache_control marker is stripped.
//
// Consequence: emptiness of a user message CANNOT be judged by `content.length`
// — at this layer the offending message is a length-1 array that looks fine. It
// must be judged by what SURVIVES this filter.
function sdkVisibleUserParts(content: readonly any[]): readonly any[] {
return content.filter((part) => !part || part.type !== "text" || part.text !== "")
}

// The SDK's ASSISTANT branch uses a slightly looser predicate — an empty text
// part survives when it carries providerOptions:
// .filter((part) => part.type !== "text" || part.text !== "" || part.providerOptions != null)
// so assistant emptiness has to be judged against that rule, not the user one.
function sdkVisibleAssistantParts(content: readonly any[]): readonly any[] {
return content.filter(
(part) => !part || part.type !== "text" || part.text !== "" || part.providerOptions != null,
)
}

// True when a message will reach the provider with no usable content.
function hasNoSendableContent(msg: ModelMessage): boolean {
const content = msg.content as unknown
if (typeof content === "string") return content === ""
if (!Array.isArray(content)) return true
// Judge each role by the SDK's own post-filter view (see the notes above).
if (msg.role === "user") return sdkVisibleUserParts(content).length === 0
if (msg.role === "assistant") return sdkVisibleAssistantParts(content).length === 0
return content.length === 0
}

// THE global pre-send content invariant: no message may reach the provider with
// empty content. This layer never existed before — `normalizeContentArray` only
// guards content SHAPE, `normalizeMessages` only strips empty parts and only for
// `@ai-sdk/anthropic`/`@ai-sdk/amazon-bedrock` (so a Bedrock-backed gateway on any
// other npm got no protection at all), and `ensureTrailingUserMessage` inspects
// only the trailing assistant. An empty user message fell through all three seams
// and produced `messages.<N>: user messages must have non-empty content`.
//
// Policy is per-role and deliberately asymmetric:
// - user → BACKFILL a minimal non-empty text turn. Dropping it would make
// the request end with an assistant message, which Bedrock rejects
// as a prefill — trading this 400 for the prefill 400.
// - assistant → DROP. It is residue with nothing to preserve, and the trailing
// user guard that runs next re-establishes the prefill invariant.
// - tool → LEAVE UNTOUCHED. A tool message's content must be `tool-result`
// blocks keyed to a preceding `tool-call`; we cannot synthesize a
// valid one, and injecting text would break tool_use/tool_result
// pairing (a different 400). The SDK's empty-text filter does not
// apply to the tool branch, and no empty tool message exists in
// any observed transcript, so there is nothing to repair here.
//
// Provider-agnostic on purpose: the AI SDK applies its stripping filter for every
// provider, so gating this on an npm package name is what created the hole.
export function ensureNonEmptyContent(msgs: ModelMessage[]): ModelMessage[] {
const result: ModelMessage[] = []
for (const msg of msgs) {
if (!hasNoSendableContent(msg)) {
result.push(msg)
continue
}
if (msg.role === "assistant") continue
if (msg.role === "tool") {
result.push(msg)
continue
}
result.push({ ...msg, content: [{ type: "text", text: EMPTY_CONTENT_PLACEHOLDER }] } as ModelMessage)
}
return result
}

// True when an assistant ModelMessage carries no renderable content (no text and
// no tool-call) — pure residue we can drop without losing anything.
function isEmptyAssistant(msg: ModelMessage): boolean {
Expand All @@ -292,17 +400,46 @@ function isEmptyAssistant(msg: ModelMessage): boolean {
// user turn is appended so the list ends with a user message. Runs at the
// pre-send choke point in `message()`, so it also self-heals history that
// already ends in an assistant turn.
//
// ORDERING CONTRACT: `ensureNonEmptyContent` MUST run before this function.
// This guard only establishes "the list ends with user/tool"; it says nothing
// about whether that trailing message has usable content. Running it first and
// resolving emptiness second would let this function return a list ending in an
// empty user message (which is what shipped, and what produced the live 400),
// and resolving emptiness afterwards could drop that message again and re-open
// the prefill 400. Emptiness first, prefill second — the two cannot fight.
export function ensureTrailingUserMessage(msgs: ModelMessage[]): ModelMessage[] {
// Drop only trailing EMPTY assistant residue (nothing to preserve).
let end = msgs.length
while (end > 0 && isEmptyAssistant(msgs[end - 1])) end--
const trimmed = end === msgs.length ? msgs : msgs.slice(0, end)
const last = trimmed[trimmed.length - 1]
// Already ends with user or tool (or empty) — safe to send as-is.
// Already ends with a user or tool message, so this is not a prefill. Their
// content is guaranteed non-empty by `ensureNonEmptyContent` (see the ordering
// contract above) — an empty trailing message is NOT safe to send as-is.
if (!last || last.role !== "assistant") return trimmed
// A content-bearing assistant is legitimately last: keep it and append a
// minimal user turn so the request ends with a user message.
return [...trimmed, { role: "user", content: CONTINUATION_PROMPT }]
//
// The content MUST be an array of parts, never a bare string. `message()` is
// typed for `ModelMessage[]` (where `content: string` is legal) but it does not
// run on `ModelMessage[]` — it runs inside the `wrapLanguageModel` middleware on
// `args.params.prompt`, a `LanguageModelV3Prompt`, whose user content is
// `Array<TextPart | FilePart>`. That mismatch is silenced by the
// `@ts-expect-error` at session/llm.ts:670 (and session/prompt.ts:596).
//
// A bare string there is not merely untidy, it is THE producer of the 400:
// @ai-sdk/anthropic's user branch does `for (let j = 0; j < content.length; j++)`
// and `switch (part.type)` with cases for only `text`/`file` and NO default
// (dist/index.mjs:2320-2408 on 3.0.82), so a string is iterated as individual
// characters whose `.type` is `undefined`, nothing is pushed, and the message
// goes out as `{"role":"user","content":[]}` — the exact trailing message in the
// observed failing request.
//
// `ensureNonEmptyContent` cannot save this: per the ordering contract it runs
// BEFORE this function, and "Continue." is not empty by any predicate, so the
// append is never re-inspected.
return [...trimmed, { role: "user", content: [{ type: "text", text: CONTINUATION_PROMPT }] } as ModelMessage]
}

// Hard prune of the trailing assistant run, discarding its content. Unlike
Expand Down Expand Up @@ -454,17 +591,36 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage
return msgs
}

// Minimal crash guard: ensure msg.content is never a non-string non-array value
// (object, undefined, null) that would blow up downstream `.map()` calls.
// Minimal crash guard: for the roles it can repair, ensure msg.content is never a
// non-string non-array value (object, undefined, null) that would blow up
// downstream `.map()` calls. NOT a blanket guarantee — `tool` is deliberately
// exempt (see below), so downstream code must still not assume array content.
// Strings are valid ModelMessage content (the AI SDK accepts content: string |
// Array) and are left untouched. Only genuinely-invalid types are normalized
// to a safe empty array so every downstream path can safely call `.map()`.
// Array) and are left untouched. Only genuinely-invalid types are normalized.
//
// Invalid content is BACKFILLED, not blanked, for roles the provider requires to
// be non-empty. Emitting `content: []` here would trade a crash for a 400
// ("user messages must have non-empty content"), and blanking a user turn also
// re-opens the trailing-assistant prefill 400 once the empty message is dropped
// downstream. An assistant gets `[]` because it carries no obligation: the
// non-empty invariant drops empty assistant residue and the trailing-user guard
// then re-establishes the prefill invariant.
//
// A `tool` message is left EXACTLY as-is, matching ensureNonEmptyContent's
// per-role policy: injecting a text part into a tool message breaks tool_use /
// tool_result pairing, which trades one 400 for another, and emitting `content:
// []` is itself illegal for a tool result. Only `user` gets the backfill.
// "Exactly as-is" is the load-bearing part: `[]` is NOT an acceptable substitute,
// and leaving the value untouched is what makes this guard and
// `ensureNonEmptyContent` reach the same outcome on the same input
// (`hasNoSendableContent` returns true for non-array content, and the tool branch
// there re-pushes the message unchanged).
function normalizeContentArray(msgs: ModelMessage[]): ModelMessage[] {
return msgs.map((msg) => {
if (typeof msg.content === "string" || Array.isArray(msg.content)) return msg
// object / undefined / null — not a valid ModelMessage content shape;
// wrap in an empty array so .map() downstream never throws.
return { ...msg, content: [] } as ModelMessage
if (msg.role === "assistant") return { ...msg, content: [] } as ModelMessage
if (msg.role === "user") return { ...msg, content: [{ type: "text", text: EMPTY_CONTENT_PLACEHOLDER }] } as ModelMessage
return msg
})
}

Expand Down Expand Up @@ -810,6 +966,11 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re
msgs = limitImages(msgs, model)
msgs = normalizeMessages(msgs, model, options)
msgs = forceAnthropicReasoningContent(msgs, model)
// Ordering is load-bearing (see ensureTrailingUserMessage's ordering contract):
// resolve EMPTY content first, then the trailing-assistant/prefill invariant.
// Emptiness is provider-agnostic because the AI SDK strips empty user text
// parts for every provider, downstream of everything here.
msgs = ensureNonEmptyContent(msgs)
// SAFE prefill guard: never let the request end with an assistant (prefill)
// message a provider (e.g. Bedrock) would reject, without deleting a completed
// reply. Drops only empty residue; appends a continuation user turn otherwise.
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/session/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,16 @@ export const layer = Layer.effect(

// Convert the conversation to native model messages so the judge sees the
// real tool calls/results/images — same context the working agent had.
const conversation = yield* MessageV2.toModelMessagesEffect(input.msgs, resolved)
//
// `ensureNonEmptyContent` is applied by hand here because this is the ONE
// persisted-parts→provider site that does not run `ProviderTransform.message`:
// `model: language` below is the RAW model, with no `wrapLanguageModel` and no
// middleware anywhere in this file, so the pre-send invariant that every other
// build site inherits from the middleware would otherwise be absent. An empty
// user message here reaches the judge's provider unrepaired.
const conversation = ProviderTransform.ensureNonEmptyContent(
yield* MessageV2.toModelMessagesEffect(input.msgs, resolved),
)

// Diagnostic: dump the FULL message array sent to the judge. Long strings
// (e.g. base64 image data) are clipped with a length marker so the log
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,12 @@ const live: Layer.Layer<
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// `generate || stream`, matching session/prompt.ts:597. This file's
// only SDK entrypoint is `streamText` (:599), so narrowing to
// "stream" is not an active hole today — but it would silently drop
// the whole transform, including the empty-content invariant, the
// moment a non-streaming call is added here.
if (args.type === "generate" || args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
Expand Down
Loading
Loading