Skip to content

Commit 203e02f

Browse files
Eric Lawsonerichasinternet
authored andcommitted
fix(opencode): filter empty text/reasoning parts when loading from database
Empty text and reasoning parts with blank or whitespace-only text can be stored in the database during streaming (from text-start events that receive no deltas, or when streams are interrupted). These empty parts cause permanent ValidationException errors with providers like AWS Bedrock, especially when using LiteLLM proxy (@ai-sdk/openai-compatible). This fix adds defensive filtering at two levels: 1. When hydrating parts from database (filters on load) 2. When converting to model messages (filters during conversion) Both filters use .trim() to catch whitespace-only content. Fixes anomalyco#19309 Complements anomalyco#17742 (prevention) with recovery for existing corruption
1 parent bcf18ed commit 203e02f

2 files changed

Lines changed: 145 additions & 3 deletions

File tree

packages/opencode/src/session/message-v2.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,10 @@ export namespace MessageV2 {
560560
)
561561
for (const row of partRows) {
562562
const next = part(row)
563+
// Filter out empty text/reasoning parts (database corruption recovery)
564+
if (next.type === "text" || next.type === "reasoning") {
565+
if (!next.text || next.text.trim().length === 0) continue
566+
}
563567
const list = partByMessage.get(row.message_id)
564568
if (list) list.push(next)
565569
else partByMessage.set(row.message_id, [next])
@@ -644,7 +648,7 @@ export namespace MessageV2 {
644648
}
645649
result.push(userMessage)
646650
for (const part of msg.parts) {
647-
if (part.type === "text" && !part.ignored)
651+
if (part.type === "text" && !part.ignored && part.text.trim())
648652
userMessage.parts.push({
649653
type: "text",
650654
text: part.text,
@@ -700,7 +704,7 @@ export namespace MessageV2 {
700704
parts: [],
701705
}
702706
for (const part of msg.parts) {
703-
if (part.type === "text")
707+
if (part.type === "text" && part.text.trim())
704708
assistantMessage.parts.push({
705709
type: "text",
706710
text: part.text,
@@ -763,7 +767,7 @@ export namespace MessageV2 {
763767
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
764768
})
765769
}
766-
if (part.type === "reasoning") {
770+
if (part.type === "reasoning" && part.text.trim()) {
767771
assistantMessage.parts.push({
768772
type: "reasoning",
769773
text: part.text,

packages/opencode/test/session/message-v2.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,3 +955,141 @@ describe("session.message-v2.fromError", () => {
955955
expect(result.name).toBe("MessageAbortedError")
956956
})
957957
})
958+
959+
describe("session.message-v2 empty parts filtering", () => {
960+
test("filters out empty text parts when converting to model messages", () => {
961+
const input: MessageV2.WithParts[] = [
962+
{
963+
info: userInfo("m-user"),
964+
parts: [
965+
{
966+
...basePart("m-user", "p1"),
967+
type: "text",
968+
text: "",
969+
},
970+
{
971+
...basePart("m-user", "p2"),
972+
type: "text",
973+
text: "hello",
974+
},
975+
] as MessageV2.Part[],
976+
},
977+
]
978+
979+
const result = MessageV2.toModelMessages(input, model)
980+
981+
expect(result).toStrictEqual([
982+
{
983+
role: "user",
984+
content: [{ type: "text", text: "hello" }],
985+
},
986+
])
987+
})
988+
989+
test("filters out whitespace-only text parts", () => {
990+
const input: MessageV2.WithParts[] = [
991+
{
992+
info: assistantInfo("m-assistant", "m-user"),
993+
parts: [
994+
{
995+
...basePart("m-assistant", "p1"),
996+
type: "text",
997+
text: " ",
998+
},
999+
{
1000+
...basePart("m-assistant", "p2"),
1001+
type: "text",
1002+
text: "actual content",
1003+
},
1004+
] as MessageV2.Part[],
1005+
},
1006+
]
1007+
1008+
const result = MessageV2.toModelMessages(input, model)
1009+
1010+
expect(result).toStrictEqual([
1011+
{
1012+
role: "assistant",
1013+
content: [{ type: "text", text: "actual content" }],
1014+
},
1015+
])
1016+
})
1017+
1018+
test("filters out empty reasoning parts", () => {
1019+
const reasoningModel: Provider.Model = {
1020+
...model,
1021+
capabilities: {
1022+
...model.capabilities,
1023+
reasoning: true,
1024+
},
1025+
}
1026+
1027+
const input: MessageV2.WithParts[] = [
1028+
{
1029+
info: assistantInfo("m-assistant", "m-user"),
1030+
parts: [
1031+
{
1032+
...basePart("m-assistant", "p1"),
1033+
type: "reasoning",
1034+
text: "",
1035+
time: { start: 0, end: 100 },
1036+
},
1037+
{
1038+
...basePart("m-assistant", "p2"),
1039+
type: "text",
1040+
text: "response",
1041+
},
1042+
] as MessageV2.Part[],
1043+
},
1044+
]
1045+
1046+
const result = MessageV2.toModelMessages(input, reasoningModel)
1047+
1048+
expect(result).toStrictEqual([
1049+
{
1050+
role: "assistant",
1051+
content: [{ type: "text", text: "response" }],
1052+
},
1053+
])
1054+
})
1055+
1056+
test("preserves non-text parts even when text is empty", () => {
1057+
const input: MessageV2.WithParts[] = [
1058+
{
1059+
info: assistantInfo("m-assistant", "m-user"),
1060+
parts: [
1061+
{
1062+
...basePart("m-assistant", "p1"),
1063+
type: "text",
1064+
text: "",
1065+
},
1066+
{
1067+
...basePart("m-assistant", "p2"),
1068+
type: "tool",
1069+
tool: "test_tool",
1070+
callID: "call_1",
1071+
state: {
1072+
status: "completed",
1073+
time: { started: 0, completed: 100 },
1074+
output: "result",
1075+
input: {},
1076+
},
1077+
},
1078+
] as MessageV2.Part[],
1079+
},
1080+
]
1081+
1082+
const result = MessageV2.toModelMessages(input, model)
1083+
1084+
// AI SDK creates 2 messages: assistant with tool-call + tool-result message
1085+
expect(result.length).toBe(2)
1086+
expect(result[0].role).toBe("assistant")
1087+
expect(result[1].role).toBe("tool")
1088+
1089+
// First message should have tool-call, no empty text
1090+
const assistantContent = result[0].content as any[]
1091+
expect(Array.isArray(assistantContent)).toBe(true)
1092+
expect(assistantContent.some((c) => c.type === "tool-call")).toBe(true)
1093+
expect(assistantContent.every((c) => c.type !== "text" || (c.text && c.text.trim() !== ""))).toBe(true)
1094+
})
1095+
})

0 commit comments

Comments
 (0)