Compare commits

...

3 Commits

Author SHA1 Message Date
Aiden Cline a26c3a0b8b fix(ai): reject stateful empty text 2026-08-21 12:53:27 -05:00
Aiden Cline 72bd1a45be fix(ai): limit empty filtering to messages 2026-08-21 12:52:07 -05:00
Aiden Cline 0197af4806 fix(ai): filter empty Anthropic text blocks 2026-08-21 11:38:58 -05:00
4 changed files with 90 additions and 6 deletions
@@ -359,6 +359,8 @@ const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): strin
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
}
const hasText = (part: { readonly text: string }) => part.text.trim().length > 0
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
name: tool.name,
description: tool.description,
@@ -534,6 +536,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicUserBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
if (!hasText(part)) continue
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
continue
}
@@ -543,7 +546,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
}
messages.push({ role: "user", content })
if (content.length > 0) messages.push({ role: "user", content })
continue
}
@@ -551,6 +554,11 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicAssistantBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
if (!hasText(part)) {
if (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
return yield* invalid("Anthropic Messages cannot discard provider state attached to empty assistant text")
continue
}
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
continue
}
@@ -579,7 +587,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
`Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`,
)
}
messages.push({ role: "assistant", content })
if (content.length > 0) messages.push({ role: "assistant", content })
continue
}
@@ -58,6 +58,79 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("filters empty user and assistant text while preserving replay state", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user(" \n\t"),
Message.user([
{ type: "text", text: "" },
{ type: "text", text: " Use the tool. " },
{ type: "text", text: " \n\t" },
]),
Message.assistant([
{ type: "text", text: "" },
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({
id: "call_1",
name: "lookup",
resultType: "text",
result: "Tool result.",
}),
Message.assistant(" \n\t"),
Message.user("Continue."),
],
cache: "none",
}),
)
expect(prepared.body).toMatchObject({
messages: [
{ role: "user", content: [{ type: "text", text: " Use the tool. " }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "", signature: "sig_1" },
{ type: "tool_use", id: "call_1", name: "lookup", input: {} },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: "Tool result.",
},
],
},
{ role: "user", content: [{ type: "text", text: "Continue." }] },
],
})
}),
)
it.effect("rejects empty assistant text carrying provider state", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "", providerMetadata: { anthropic: { encryptedContent: "opaque" } } },
]),
],
}),
).pipe(Effect.flip)
expect(error.message).toContain("cannot discard provider state attached to empty assistant text")
}),
)
it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -7,6 +7,9 @@ import type { FileAttachment } from "@opencode-ai/schema/prompt"
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
const hasProviderMetadata = (metadata: ProviderMetadata | undefined) =>
metadata !== undefined && Object.keys(metadata).length > 0
const media = (file: FileAttachment): ContentPart => ({
type: "media",
mediaType: file.mime,
@@ -188,9 +191,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
return result ? [call, result] : [call]
})
const meaningful = content.filter((part) => {
if (part.type === "text") return part.text !== ""
if (part.type === "text") return part.text !== "" || hasProviderMetadata(part.providerMetadata)
if (part.type !== "reasoning") return true
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
return part.text !== "" || hasProviderMetadata(part.providerMetadata)
})
const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.executed !== true)
@@ -1031,7 +1031,7 @@ Recent work
content: [
SessionMessage.AssistantText.make({
type: "text",
text: "Checking.",
text: "",
state: { phase: "commentary" },
}),
],
@@ -1045,7 +1045,7 @@ Recent work
expect(messages[0]?.content).toEqual([
{
type: "text",
text: "Checking.",
text: "",
providerMetadata: { provider: { phase: "commentary" } },
},
])