Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit bbdcab3

Browse files
committed
fix: validate deserialized API messages to prevent undefined content errors
Root cause: JSON.parse() in readApiMessages() deserializes data with zero validation, allowing corrupted or legacy data to violate type contracts (e.g., undefined content). This causes downstream crashes in functions like filterNonAnthropicBlocks() that assume message.content is always string | ContentBlock[] per the type system, but receive undefined/null values from corrupted historical data. Solution: - Add validateApiMessage() to sanitize deserialized messages at the boundary - Fix undefined/null/non-array content by converting to empty array - Filter out malformed messages with invalid roles or non-object structure - Filter out invalid content blocks missing the required type field - Add comprehensive test coverage (12 tests covering all edge cases) Fixes: https://www.reddit.com/r/RooCode/s/yUXVFMYu3P Related to closed PR #10954 (which treated symptom, not cause)
1 parent 953c777 commit bbdcab3

2 files changed

Lines changed: 337 additions & 2 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
2+
import * as fs from "fs/promises"
3+
import * as path from "path"
4+
import { readApiMessages, saveApiMessages } from "../apiMessages"
5+
6+
// Mock dependencies
7+
vi.mock("../../../utils/fs")
8+
vi.mock("../../../utils/storage")
9+
vi.mock("fs/promises")
10+
vi.mock("../../../utils/safeWriteJson")
11+
12+
const { fileExistsAtPath } = await import("../../../utils/fs")
13+
const { getTaskDirectoryPath } = await import("../../../utils/storage")
14+
const { safeWriteJson } = await import("../../../utils/safeWriteJson")
15+
16+
describe("apiMessages", () => {
17+
const mockTaskId = "test-task-id"
18+
const mockGlobalStoragePath = "/mock/storage"
19+
const mockTaskDir = "/mock/storage/tasks/test-task-id"
20+
const mockFilePath = path.join(mockTaskDir, "api_conversation_history.json")
21+
22+
beforeEach(() => {
23+
vi.clearAllMocks()
24+
vi.mocked(getTaskDirectoryPath).mockResolvedValue(mockTaskDir)
25+
})
26+
27+
afterEach(() => {
28+
vi.resetAllMocks()
29+
})
30+
31+
describe("readApiMessages - validation", () => {
32+
it("should successfully read and return valid messages", async () => {
33+
const validMessages = [
34+
{ role: "user", content: "Hello" },
35+
{ role: "assistant", content: [{ type: "text", text: "Hi there!" }] },
36+
]
37+
38+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
39+
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(validMessages))
40+
41+
const result = await readApiMessages({ taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
42+
43+
expect(result).toHaveLength(2)
44+
expect(result[0]).toEqual({ role: "user", content: "Hello" })
45+
expect(result[1]).toEqual({ role: "assistant", content: [{ type: "text", text: "Hi there!" }] })
46+
})
47+
48+
it("should fix messages with undefined content", async () => {
49+
const messagesWithUndefinedContent = [
50+
{ role: "user", content: "Valid" },
51+
{ role: "assistant", content: undefined },
52+
{ role: "user", content: "Continue" },
53+
]
54+
55+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
56+
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(messagesWithUndefinedContent))
57+
58+
const result = await readApiMessages({ taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
59+
60+
expect(result).toHaveLength(3)
61+
expect(result[0].content).toBe("Valid")
62+
expect(result[1].content).toEqual([]) // Fixed from undefined to empty array
63+
expect(result[2].content).toBe("Continue")
64+
})
65+
66+
it("should fix messages with null content", async () => {
67+
const messagesWithNullContent = [
68+
{ role: "user", content: "Valid" },
69+
{ role: "assistant", content: null },
70+
{ role: "user", content: "Continue" },
71+
]
72+
73+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
74+
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(messagesWithNullContent))
75+
76+
const result = await readApiMessages({ taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
77+
78+
expect(result).toHaveLength(3)
79+
expect(result[0].content).toBe("Valid")
80+
expect(result[1].content).toEqual([]) // Fixed from null to empty array
81+
expect(result[2].content).toBe("Continue")
82+
})
83+
84+
it("should fix messages with non-array, non-string content", async () => {
85+
const messagesWithInvalidContent = [
86+
{ role: "user", content: "Valid" },
87+
{ role: "assistant", content: { invalid: "object" } },
88+
{ role: "user", content: "Continue" },
89+
]
90+
91+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
92+
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(messagesWithInvalidContent))
93+
94+
const result = await readApiMessages({ taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
95+
96+
expect(result).toHaveLength(3)
97+
expect(result[0].content).toBe("Valid")
98+
expect(result[1].content).toEqual([]) // Fixed from object to empty array
99+
expect(result[2].content).toBe("Continue")
100+
})
101+
102+
it("should filter out messages with invalid roles", async () => {
103+
const messagesWithInvalidRoles = [
104+
{ role: "user", content: "Valid" },
105+
{ role: "invalid_role", content: "Bad message" },
106+
{ role: "assistant", content: "Also valid" },
107+
]
108+
109+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
110+
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(messagesWithInvalidRoles))
111+
112+
const result = await readApiMessages({ taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
113+
114+
expect(result).toHaveLength(2)
115+
expect(result[0].role).toBe("user")
116+
expect(result[1].role).toBe("assistant")
117+
})
118+
119+
it("should filter out completely malformed messages (not objects)", async () => {
120+
const messagesWithNonObjects = [
121+
{ role: "user", content: "Valid" },
122+
null,
123+
"string message",
124+
123,
125+
{ role: "assistant", content: "Also valid" },
126+
]
127+
128+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
129+
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(messagesWithNonObjects))
130+
131+
const result = await readApiMessages({ taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
132+
133+
expect(result).toHaveLength(2)
134+
expect(result[0].role).toBe("user")
135+
expect(result[1].role).toBe("assistant")
136+
})
137+
138+
it("should filter out invalid blocks from message content arrays", async () => {
139+
const messagesWithInvalidBlocks = [
140+
{
141+
role: "assistant",
142+
content: [
143+
{ type: "text", text: "Valid text" },
144+
null, // Invalid block
145+
{ invalid: "no type field" }, // Invalid block
146+
{ type: "text", text: "Another valid text" },
147+
],
148+
},
149+
]
150+
151+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
152+
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(messagesWithInvalidBlocks))
153+
154+
const result = await readApiMessages({ taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
155+
156+
expect(result).toHaveLength(1)
157+
expect(Array.isArray(result[0].content)).toBe(true)
158+
expect((result[0].content as any[]).length).toBe(2)
159+
expect((result[0].content as any[])[0]).toEqual({ type: "text", text: "Valid text" })
160+
expect((result[0].content as any[])[1]).toEqual({ type: "text", text: "Another valid text" })
161+
})
162+
163+
it("should return empty array when parsedData is not an array", async () => {
164+
const nonArrayData = { invalid: "not an array" }
165+
166+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
167+
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(nonArrayData))
168+
169+
const result = await readApiMessages({ taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
170+
171+
expect(result).toEqual([])
172+
})
173+
174+
it("should preserve valid metadata fields (ts, isSummary, etc.)", async () => {
175+
const messagesWithMetadata = [
176+
{
177+
role: "user",
178+
content: "Test",
179+
ts: 1234567890,
180+
isSummary: true,
181+
condenseId: "c-123",
182+
truncationId: "t-456",
183+
},
184+
]
185+
186+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
187+
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(messagesWithMetadata))
188+
189+
const result = await readApiMessages({ taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
190+
191+
expect(result).toHaveLength(1)
192+
expect(result[0].ts).toBe(1234567890)
193+
expect(result[0].isSummary).toBe(true)
194+
expect(result[0].condenseId).toBe("c-123")
195+
expect(result[0].truncationId).toBe("t-456")
196+
})
197+
198+
it("should handle old claude_messages.json format with validation", async () => {
199+
const oldFormatMessages = [
200+
{ role: "user", content: "Valid" },
201+
{ role: "assistant", content: undefined }, // Should be fixed
202+
]
203+
204+
// New format doesn't exist, old format exists
205+
vi.mocked(fileExistsAtPath)
206+
.mockResolvedValueOnce(false) // New format
207+
.mockResolvedValueOnce(true) // Old format
208+
209+
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(oldFormatMessages))
210+
// Mock unlink to prevent errors
211+
;(fs as any).unlink = vi.fn().mockResolvedValue(undefined)
212+
213+
const result = await readApiMessages({ taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
214+
215+
expect(result).toHaveLength(2)
216+
expect(result[0].content).toBe("Valid")
217+
expect(result[1].content).toEqual([]) // Fixed from undefined
218+
})
219+
220+
it("should return empty array when no file exists", async () => {
221+
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
222+
223+
const result = await readApiMessages({ taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
224+
225+
expect(result).toEqual([])
226+
})
227+
})
228+
229+
describe("saveApiMessages", () => {
230+
it("should call safeWriteJson with correct parameters", async () => {
231+
const messages = [
232+
{ role: "user", content: "Test" },
233+
{ role: "assistant", content: [{ type: "text", text: "Response" }] },
234+
] as any[]
235+
236+
await saveApiMessages({ messages, taskId: mockTaskId, globalStoragePath: mockGlobalStoragePath })
237+
238+
expect(safeWriteJson).toHaveBeenCalledWith(mockFilePath, messages)
239+
})
240+
})
241+
})

src/core/task-persistence/apiMessages.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,62 @@ import { fileExistsAtPath } from "../../utils/fs"
99
import { GlobalFileNames } from "../../shared/globalFileNames"
1010
import { getTaskDirectoryPath } from "../../utils/storage"
1111

12+
/**
13+
* Validates and sanitizes a deserialized API message to ensure it meets type contract.
14+
* Fixes common corruption issues like undefined/null/non-array content.
15+
*
16+
* @param message - Raw deserialized message object
17+
* @param taskId - Task ID for logging purposes
18+
* @param index - Message index for logging purposes
19+
* @returns Validated message or null if the message is irreparably malformed
20+
*/
21+
function validateApiMessage(message: any, taskId: string, index: number): ApiMessage | null {
22+
// Basic structure validation
23+
if (!message || typeof message !== "object") {
24+
console.error(
25+
`[readApiMessages] Invalid message at index ${index} for task ${taskId}: not an object. Skipping.`,
26+
)
27+
return null
28+
}
29+
30+
// Validate role
31+
if (message.role !== "user" && message.role !== "assistant") {
32+
console.error(
33+
`[readApiMessages] Invalid message role at index ${index} for task ${taskId}: "${message.role}". Skipping.`,
34+
)
35+
return null
36+
}
37+
38+
// Validate and fix content field
39+
if (message.content === undefined || message.content === null) {
40+
// Content is missing - fix it with empty array for array content or empty string for string content
41+
console.warn(
42+
`[readApiMessages] Message at index ${index} for task ${taskId} has undefined/null content. Fixing with empty array.`,
43+
)
44+
message.content = []
45+
} else if (!Array.isArray(message.content) && typeof message.content !== "string") {
46+
// Content is wrong type - try to recover
47+
console.warn(
48+
`[readApiMessages] Message at index ${index} for task ${taskId} has invalid content type (${typeof message.content}). Fixing with empty array.`,
49+
)
50+
message.content = []
51+
} else if (Array.isArray(message.content)) {
52+
// Content is an array - validate each block has a type
53+
const validatedContent = message.content.filter((block: any) => {
54+
if (!block || typeof block !== "object" || typeof block.type !== "string") {
55+
console.warn(
56+
`[readApiMessages] Removing invalid content block at index ${index} for task ${taskId}: missing or invalid type field.`,
57+
)
58+
return false
59+
}
60+
return true
61+
})
62+
message.content = validatedContent
63+
}
64+
65+
return message as ApiMessage
66+
}
67+
1268
export type ApiMessage = Anthropic.MessageParam & {
1369
ts?: number
1470
isSummary?: boolean
@@ -56,7 +112,26 @@ export async function readApiMessages({
56112
`[Roo-Debug] readApiMessages: Found API conversation history file, but it's empty (parsed as []). TaskId: ${taskId}, Path: ${filePath}`,
57113
)
58114
}
59-
return parsedData
115+
116+
// Validate and sanitize each message
117+
if (!Array.isArray(parsedData)) {
118+
console.error(
119+
`[readApiMessages] Parsed data is not an array for task ${taskId}. Returning empty array.`,
120+
)
121+
return []
122+
}
123+
124+
const validatedMessages = parsedData
125+
.map((msg, index) => validateApiMessage(msg, taskId, index))
126+
.filter((msg): msg is ApiMessage => msg !== null)
127+
128+
if (validatedMessages.length < parsedData.length) {
129+
console.warn(
130+
`[readApiMessages] Filtered out ${parsedData.length - validatedMessages.length} invalid messages for task ${taskId}`,
131+
)
132+
}
133+
134+
return validatedMessages
60135
} catch (error) {
61136
console.error(
62137
`[Roo-Debug] readApiMessages: Error parsing API conversation history file. TaskId: ${taskId}, Path: ${filePath}, Error: ${error}`,
@@ -75,8 +150,27 @@ export async function readApiMessages({
75150
`[Roo-Debug] readApiMessages: Found OLD API conversation history file (claude_messages.json), but it's empty (parsed as []). TaskId: ${taskId}, Path: ${oldPath}`,
76151
)
77152
}
153+
154+
// Validate and sanitize each message
155+
if (!Array.isArray(parsedData)) {
156+
console.error(
157+
`[readApiMessages] Parsed old data is not an array for task ${taskId}. Returning empty array.`,
158+
)
159+
return []
160+
}
161+
162+
const validatedMessages = parsedData
163+
.map((msg, index) => validateApiMessage(msg, taskId, index))
164+
.filter((msg): msg is ApiMessage => msg !== null)
165+
166+
if (validatedMessages.length < parsedData.length) {
167+
console.warn(
168+
`[readApiMessages] Filtered out ${parsedData.length - validatedMessages.length} invalid messages from old format for task ${taskId}`,
169+
)
170+
}
171+
78172
await fs.unlink(oldPath)
79-
return parsedData
173+
return validatedMessages
80174
} catch (error) {
81175
console.error(
82176
`[Roo-Debug] readApiMessages: Error parsing OLD API conversation history file (claude_messages.json). TaskId: ${taskId}, Path: ${oldPath}, Error: ${error}`,

0 commit comments

Comments
 (0)