Răsfoiți Sursa

feat: RooMessage type system and storage layer for ModelMessage migration (#11380)

Co-authored-by: Roo Code <[email protected]>
Daniel 3 zile în urmă
părinte
comite
50e5d98f5b

+ 115 - 0
src/core/task-persistence/__tests__/messageUtils.spec.ts

@@ -0,0 +1,115 @@
+import type { ModelMessage } from "ai"
+import { flattenModelMessagesToStringContent } from "../messageUtils"
+
+describe("flattenModelMessagesToStringContent", () => {
+	test("flattens user messages with all text parts to string", () => {
+		const messages: ModelMessage[] = [
+			{
+				role: "user",
+				content: [
+					{ type: "text", text: "Part 1" },
+					{ type: "text", text: "Part 2" },
+				],
+			} as ModelMessage,
+		]
+		const result = flattenModelMessagesToStringContent(messages)
+		expect(result[0].content).toBe("Part 1\nPart 2")
+	})
+
+	test("does not flatten user messages with non-text parts", () => {
+		const messages: ModelMessage[] = [
+			{
+				role: "user",
+				content: [
+					{ type: "text", text: "Some text" },
+					{ type: "image", image: "data:image/png;base64,abc=" },
+				],
+			} as ModelMessage,
+		]
+		const result = flattenModelMessagesToStringContent(messages)
+		expect(Array.isArray(result[0].content)).toBe(true)
+	})
+
+	test("flattens assistant messages with text-only parts", () => {
+		const messages: ModelMessage[] = [
+			{
+				role: "assistant",
+				content: [
+					{ type: "text", text: "Response part 1" },
+					{ type: "text", text: "Response part 2" },
+				],
+			} as ModelMessage,
+		]
+		const result = flattenModelMessagesToStringContent(messages)
+		expect(result[0].content).toBe("Response part 1\nResponse part 2")
+	})
+
+	test("flattens assistant messages with text + reasoning (strips reasoning)", () => {
+		const messages: ModelMessage[] = [
+			{
+				role: "assistant",
+				content: [
+					{ type: "reasoning", text: "Thinking..." },
+					{ type: "text", text: "The answer" },
+				],
+			} as ModelMessage,
+		]
+		const result = flattenModelMessagesToStringContent(messages)
+		expect(result[0].content).toBe("The answer")
+	})
+
+	test("does not flatten assistant messages with tool calls", () => {
+		const messages: ModelMessage[] = [
+			{
+				role: "assistant",
+				content: [
+					{ type: "text", text: "Let me help" },
+					{ type: "tool-call", toolCallId: "c1", toolName: "read_file", input: {} },
+				],
+			} as ModelMessage,
+		]
+		const result = flattenModelMessagesToStringContent(messages)
+		expect(Array.isArray(result[0].content)).toBe(true)
+	})
+
+	test("skips already-string content", () => {
+		const messages: ModelMessage[] = [{ role: "user", content: "Already a string" }]
+		const result = flattenModelMessagesToStringContent(messages)
+		expect(result[0].content).toBe("Already a string")
+	})
+
+	test("respects flattenUserMessages=false", () => {
+		const messages: ModelMessage[] = [
+			{
+				role: "user",
+				content: [{ type: "text", text: "Part 1" }],
+			} as ModelMessage,
+		]
+		const result = flattenModelMessagesToStringContent(messages, { flattenUserMessages: false })
+		expect(Array.isArray(result[0].content)).toBe(true)
+	})
+
+	test("respects flattenAssistantMessages=false", () => {
+		const messages: ModelMessage[] = [
+			{
+				role: "assistant",
+				content: [{ type: "text", text: "Part 1" }],
+			} as ModelMessage,
+		]
+		const result = flattenModelMessagesToStringContent(messages, { flattenAssistantMessages: false })
+		expect(Array.isArray(result[0].content)).toBe(true)
+	})
+
+	test("does not modify tool messages", () => {
+		const messages: ModelMessage[] = [
+			{
+				role: "tool",
+				content: [
+					{ type: "tool-result", toolCallId: "c1", toolName: "test", output: { type: "text", value: "ok" } },
+				],
+			} as ModelMessage,
+		]
+		const result = flattenModelMessagesToStringContent(messages)
+		expect(Array.isArray(result[0].content)).toBe(true)
+	})
+})

+ 229 - 0
src/core/task-persistence/__tests__/rooMessage.spec.ts

@@ -0,0 +1,229 @@
+import {
+	ROO_MESSAGE_VERSION,
+	isRooUserMessage,
+	isRooAssistantMessage,
+	isRooToolMessage,
+	isRooReasoningMessage,
+	type RooMessage,
+	type RooUserMessage,
+	type RooAssistantMessage,
+	type RooToolMessage,
+	type RooReasoningMessage,
+	type TextPart,
+	type ImagePart,
+	type FilePart,
+	type ToolCallPart,
+	type ToolResultPart,
+	type ReasoningPart,
+	type RooMessageMetadata,
+	type RooMessageHistory,
+} from "../rooMessage"
+
+// ────────────────────────────────────────────────────────────────────────────
+// Fixtures
+// ────────────────────────────────────────────────────────────────────────────
+
+const userMessageString: RooUserMessage = {
+	role: "user",
+	content: "Hello, world!",
+	ts: 1000,
+}
+
+const userMessageParts: RooUserMessage = {
+	role: "user",
+	content: [
+		{ type: "text", text: "Describe this image:" },
+		{ type: "image", image: "data:image/png;base64,abc", mediaType: "image/png" },
+		{ type: "file", data: "base64data", mediaType: "application/pdf" },
+	],
+}
+
+const assistantMessageString: RooAssistantMessage = {
+	role: "assistant",
+	content: "Sure, I can help with that.",
+	id: "resp_123",
+}
+
+const assistantMessageParts: RooAssistantMessage = {
+	role: "assistant",
+	content: [
+		{
+			type: "reasoning",
+			text: "Let me think about this...",
+			providerOptions: { anthropic: { signature: "sig123" } },
+		},
+		{ type: "text", text: "Here is the answer." },
+		{ type: "tool-call", toolCallId: "call_1", toolName: "readFile", input: { path: "/tmp/foo" } },
+	],
+	providerOptions: { openai: { reasoning_details: {} } },
+}
+
+const toolMessage: RooToolMessage = {
+	role: "tool",
+	content: [
+		{
+			type: "tool-result",
+			toolCallId: "call_1",
+			toolName: "readFile",
+			output: { type: "text", value: "file contents here" },
+		},
+	],
+}
+
+const reasoningMessage: RooReasoningMessage = {
+	type: "reasoning",
+	encrypted_content: "encrypted_base64_data",
+	id: "reasoning_1",
+	summary: [{ type: "text", text: "Summary of reasoning" }],
+	ts: 2000,
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// Tests
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("ROO_MESSAGE_VERSION", () => {
+	it("should be 2", () => {
+		expect(ROO_MESSAGE_VERSION).toBe(2)
+	})
+})
+
+describe("isRooUserMessage", () => {
+	it("returns true for a user message with string content", () => {
+		expect(isRooUserMessage(userMessageString)).toBe(true)
+	})
+
+	it("returns true for a user message with content parts", () => {
+		expect(isRooUserMessage(userMessageParts)).toBe(true)
+	})
+
+	it("returns false for an assistant message", () => {
+		expect(isRooUserMessage(assistantMessageString)).toBe(false)
+	})
+
+	it("returns false for a tool message", () => {
+		expect(isRooUserMessage(toolMessage)).toBe(false)
+	})
+
+	it("returns false for a reasoning message", () => {
+		expect(isRooUserMessage(reasoningMessage)).toBe(false)
+	})
+})
+
+describe("isRooAssistantMessage", () => {
+	it("returns true for an assistant message with string content", () => {
+		expect(isRooAssistantMessage(assistantMessageString)).toBe(true)
+	})
+
+	it("returns true for an assistant message with content parts", () => {
+		expect(isRooAssistantMessage(assistantMessageParts)).toBe(true)
+	})
+
+	it("returns false for a user message", () => {
+		expect(isRooAssistantMessage(userMessageString)).toBe(false)
+	})
+
+	it("returns false for a tool message", () => {
+		expect(isRooAssistantMessage(toolMessage)).toBe(false)
+	})
+
+	it("returns false for a reasoning message", () => {
+		expect(isRooAssistantMessage(reasoningMessage)).toBe(false)
+	})
+})
+
+describe("isRooToolMessage", () => {
+	it("returns true for a tool message", () => {
+		expect(isRooToolMessage(toolMessage)).toBe(true)
+	})
+
+	it("returns false for a user message", () => {
+		expect(isRooToolMessage(userMessageString)).toBe(false)
+	})
+
+	it("returns false for an assistant message", () => {
+		expect(isRooToolMessage(assistantMessageString)).toBe(false)
+	})
+
+	it("returns false for a reasoning message", () => {
+		expect(isRooToolMessage(reasoningMessage)).toBe(false)
+	})
+})
+
+describe("isRooReasoningMessage", () => {
+	it("returns true for a standalone reasoning message", () => {
+		expect(isRooReasoningMessage(reasoningMessage)).toBe(true)
+	})
+
+	it("returns false for a user message", () => {
+		expect(isRooReasoningMessage(userMessageString)).toBe(false)
+	})
+
+	it("returns false for an assistant message", () => {
+		expect(isRooReasoningMessage(assistantMessageString)).toBe(false)
+	})
+
+	it("returns false for a tool message", () => {
+		expect(isRooReasoningMessage(toolMessage)).toBe(false)
+	})
+})
+
+describe("type guard narrowing", () => {
+	it("narrows RooMessage union to the correct type", () => {
+		const messages: RooMessage[] = [userMessageString, assistantMessageParts, toolMessage, reasoningMessage]
+
+		const users = messages.filter(isRooUserMessage)
+		const assistants = messages.filter(isRooAssistantMessage)
+		const tools = messages.filter(isRooToolMessage)
+		const reasoning = messages.filter(isRooReasoningMessage)
+
+		expect(users).toHaveLength(1)
+		expect(users[0].role).toBe("user")
+
+		expect(assistants).toHaveLength(1)
+		expect(assistants[0].role).toBe("assistant")
+
+		expect(tools).toHaveLength(1)
+		expect(tools[0].role).toBe("tool")
+
+		expect(reasoning).toHaveLength(1)
+		expect(reasoning[0].type).toBe("reasoning")
+		expect(reasoning[0].encrypted_content).toBe("encrypted_base64_data")
+	})
+})
+
+describe("RooMessageMetadata", () => {
+	it("allows metadata fields on all message types", () => {
+		const msgWithMetadata: RooUserMessage = {
+			role: "user",
+			content: "test",
+			ts: 12345,
+			condenseId: "cond-1",
+			condenseParent: "cond-0",
+			truncationId: "trunc-1",
+			truncationParent: "trunc-0",
+			isTruncationMarker: true,
+			isSummary: true,
+		}
+
+		expect(msgWithMetadata.ts).toBe(12345)
+		expect(msgWithMetadata.condenseId).toBe("cond-1")
+		expect(msgWithMetadata.condenseParent).toBe("cond-0")
+		expect(msgWithMetadata.truncationId).toBe("trunc-1")
+		expect(msgWithMetadata.truncationParent).toBe("trunc-0")
+		expect(msgWithMetadata.isTruncationMarker).toBe(true)
+		expect(msgWithMetadata.isSummary).toBe(true)
+	})
+})
+
+describe("RooMessageHistory", () => {
+	it("wraps messages with the correct version", () => {
+		const history: RooMessageHistory = {
+			version: 2,
+			messages: [userMessageString, assistantMessageString, toolMessage, reasoningMessage],
+		}
+
+		expect(history.version).toBe(ROO_MESSAGE_VERSION)
+		expect(history.messages).toHaveLength(4)
+	})
+})

+ 277 - 0
src/core/task-persistence/__tests__/rooMessages.spec.ts

@@ -0,0 +1,277 @@
+// cd src && npx vitest run core/task-persistence/__tests__/rooMessages.spec.ts
+
+import * as os from "os"
+import * as path from "path"
+import * as fs from "fs/promises"
+
+import { detectFormat, readRooMessages, saveRooMessages } from "../apiMessages"
+import type { ApiMessage } from "../apiMessages"
+import type { RooMessage, RooMessageHistory } from "../rooMessage"
+import { ROO_MESSAGE_VERSION } from "../rooMessage"
+import * as safeWriteJsonModule from "../../../utils/safeWriteJson"
+
+let tmpBaseDir: string
+
+beforeEach(async () => {
+	tmpBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-roo-msgs-"))
+})
+
+afterEach(async () => {
+	await fs.rm(tmpBaseDir, { recursive: true, force: true }).catch(() => {})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// Helper: create a task directory and write a file into it
+// ────────────────────────────────────────────────────────────────────────────
+
+async function writeTaskFile(taskId: string, filename: string, content: string): Promise<string> {
+	const taskDir = path.join(tmpBaseDir, "tasks", taskId)
+	await fs.mkdir(taskDir, { recursive: true })
+	const filePath = path.join(taskDir, filename)
+	await fs.writeFile(filePath, content, "utf8")
+	return filePath
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// Sample data
+// ────────────────────────────────────────────────────────────────────────────
+
+const sampleRooMessages: RooMessage[] = [
+	{ role: "user", content: "Hello" },
+	{ role: "assistant", content: "Hi there!" },
+]
+
+const sampleV2Envelope: RooMessageHistory = {
+	version: 2,
+	messages: sampleRooMessages,
+}
+
+const sampleLegacyMessages: ApiMessage[] = [
+	{ role: "user", content: "Hello from legacy", ts: 1000 },
+	{ role: "assistant", content: "Legacy response", ts: 2000 },
+]
+
+// ────────────────────────────────────────────────────────────────────────────
+// detectFormat
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("detectFormat", () => {
+	it('returns "v2" for a valid RooMessageHistory envelope', () => {
+		expect(detectFormat({ version: 2, messages: [] })).toBe("v2")
+		expect(detectFormat({ version: 2, messages: [{ role: "user", content: "hi" }] })).toBe("v2")
+	})
+
+	it('returns "legacy" for a plain array', () => {
+		expect(detectFormat([])).toBe("legacy")
+		expect(detectFormat([{ role: "user", content: "hello" }])).toBe("legacy")
+	})
+
+	it('returns "legacy" for a non-object value', () => {
+		expect(detectFormat(null)).toBe("legacy")
+		expect(detectFormat(undefined)).toBe("legacy")
+		expect(detectFormat("string")).toBe("legacy")
+		expect(detectFormat(42)).toBe("legacy")
+	})
+
+	it('returns "legacy" for an object without version field', () => {
+		expect(detectFormat({ messages: [] })).toBe("legacy")
+	})
+
+	it('returns "legacy" for an object with wrong version', () => {
+		expect(detectFormat({ version: 1, messages: [] })).toBe("legacy")
+		expect(detectFormat({ version: 3, messages: [] })).toBe("legacy")
+	})
+
+	it('returns "legacy" for an object with version 2 but no messages array', () => {
+		expect(detectFormat({ version: 2 })).toBe("legacy")
+		expect(detectFormat({ version: 2, messages: "not-array" })).toBe("legacy")
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// readRooMessages
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("readRooMessages", () => {
+	it("reads v2 format and returns messages directly", async () => {
+		await writeTaskFile("task-v2", "api_conversation_history.json", JSON.stringify(sampleV2Envelope))
+
+		const result = await readRooMessages({ taskId: "task-v2", globalStoragePath: tmpBaseDir })
+
+		expect(result).toEqual(sampleRooMessages)
+	})
+
+	it("reads legacy format and auto-converts to RooMessage", async () => {
+		await writeTaskFile("task-legacy", "api_conversation_history.json", JSON.stringify(sampleLegacyMessages))
+
+		const result = await readRooMessages({ taskId: "task-legacy", globalStoragePath: tmpBaseDir })
+
+		expect(result.length).toBeGreaterThan(0)
+		expect(result[0]).toHaveProperty("role", "user")
+		expect(result[1]).toHaveProperty("role", "assistant")
+		// Verify metadata (ts) is preserved through conversion
+		expect(result[0]).toHaveProperty("ts", 1000)
+		expect(result[1]).toHaveProperty("ts", 2000)
+	})
+
+	it("reads legacy claude_messages.json as fallback and converts", async () => {
+		const taskDir = path.join(tmpBaseDir, "tasks", "task-old")
+		await fs.mkdir(taskDir, { recursive: true })
+		// Only write claude_messages.json, NOT api_conversation_history.json
+		await fs.writeFile(path.join(taskDir, "claude_messages.json"), JSON.stringify(sampleLegacyMessages), "utf8")
+
+		const result = await readRooMessages({ taskId: "task-old", globalStoragePath: tmpBaseDir })
+
+		expect(result.length).toBeGreaterThan(0)
+		expect(result[0]).toHaveProperty("role", "user")
+	})
+
+	it("returns empty array for an empty JSON array", async () => {
+		await writeTaskFile("task-empty", "api_conversation_history.json", JSON.stringify([]))
+
+		const result = await readRooMessages({ taskId: "task-empty", globalStoragePath: tmpBaseDir })
+
+		expect(result).toEqual([])
+	})
+
+	it("returns empty array for v2 envelope with empty messages", async () => {
+		const envelope: RooMessageHistory = { version: 2, messages: [] }
+		await writeTaskFile("task-empty-v2", "api_conversation_history.json", JSON.stringify(envelope))
+
+		const result = await readRooMessages({ taskId: "task-empty-v2", globalStoragePath: tmpBaseDir })
+
+		expect(result).toEqual([])
+	})
+
+	it("returns empty array with warning for invalid JSON", async () => {
+		const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+		await writeTaskFile("task-corrupt", "api_conversation_history.json", "<<<corrupt>>>")
+
+		const result = await readRooMessages({ taskId: "task-corrupt", globalStoragePath: tmpBaseDir })
+
+		expect(result).toEqual([])
+		expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("[readRooMessages] Error parsing file"))
+
+		warnSpy.mockRestore()
+	})
+
+	it("returns empty array with warning for non-array legacy data", async () => {
+		const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+		await writeTaskFile("task-obj", "api_conversation_history.json", JSON.stringify({ not: "an array" }))
+
+		const result = await readRooMessages({ taskId: "task-obj", globalStoragePath: tmpBaseDir })
+
+		expect(result).toEqual([])
+		expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("[readRooMessages] Parsed data is not an array"))
+
+		warnSpy.mockRestore()
+	})
+
+	it("returns empty array when no history file exists", async () => {
+		const taskDir = path.join(tmpBaseDir, "tasks", "task-none")
+		await fs.mkdir(taskDir, { recursive: true })
+
+		const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+
+		const result = await readRooMessages({ taskId: "task-none", globalStoragePath: tmpBaseDir })
+
+		expect(result).toEqual([])
+		expect(errorSpy).toHaveBeenCalledWith(
+			expect.stringContaining("[Roo-Debug] readRooMessages: API conversation history file not found"),
+		)
+
+		errorSpy.mockRestore()
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// saveRooMessages
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("saveRooMessages", () => {
+	it("saves messages in v2 envelope format", async () => {
+		const taskDir = path.join(tmpBaseDir, "tasks", "task-save")
+		await fs.mkdir(taskDir, { recursive: true })
+
+		const success = await saveRooMessages({
+			messages: sampleRooMessages,
+			taskId: "task-save",
+			globalStoragePath: tmpBaseDir,
+		})
+
+		expect(success).toBe(true)
+
+		const filePath = path.join(taskDir, "api_conversation_history.json")
+		const raw = await fs.readFile(filePath, "utf8")
+		const parsed = JSON.parse(raw)
+
+		expect(parsed).toHaveProperty("version", ROO_MESSAGE_VERSION)
+		expect(parsed).toHaveProperty("messages")
+		expect(parsed.messages).toEqual(sampleRooMessages)
+	})
+
+	it("returns false on write failure", async () => {
+		const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+
+		// Mock safeWriteJson to reject, rather than relying on OS-specific filesystem behavior
+		// (e.g. Windows can create /nonexistent/... paths under the current drive root)
+		vi.spyOn(safeWriteJsonModule, "safeWriteJson").mockRejectedValueOnce(new Error("simulated write failure"))
+
+		const taskDir = path.join(tmpBaseDir, "tasks", "task-fail")
+		await fs.mkdir(taskDir, { recursive: true })
+
+		const success = await saveRooMessages({
+			messages: sampleRooMessages,
+			taskId: "task-fail",
+			globalStoragePath: tmpBaseDir,
+		})
+
+		expect(success).toBe(false)
+		expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("[saveRooMessages] Failed to save messages"))
+
+		errorSpy.mockRestore()
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// Round-trip tests
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("round-trip", () => {
+	it("save v2 → read v2 produces identical messages", async () => {
+		const taskDir = path.join(tmpBaseDir, "tasks", "task-roundtrip")
+		await fs.mkdir(taskDir, { recursive: true })
+
+		await saveRooMessages({
+			messages: sampleRooMessages,
+			taskId: "task-roundtrip",
+			globalStoragePath: tmpBaseDir,
+		})
+
+		const result = await readRooMessages({ taskId: "task-roundtrip", globalStoragePath: tmpBaseDir })
+
+		expect(result).toEqual(sampleRooMessages)
+	})
+
+	it("legacy read → save → read produces consistent RooMessages", async () => {
+		const taskId = "task-legacy-roundtrip"
+		await writeTaskFile(taskId, "api_conversation_history.json", JSON.stringify(sampleLegacyMessages))
+
+		// First read: converts legacy to RooMessage
+		const converted = await readRooMessages({ taskId, globalStoragePath: tmpBaseDir })
+		expect(converted.length).toBeGreaterThan(0)
+
+		// Save the converted messages (now in v2 format)
+		await saveRooMessages({ messages: converted, taskId, globalStoragePath: tmpBaseDir })
+
+		// Second read: should read v2 format directly
+		const reloaded = await readRooMessages({ taskId, globalStoragePath: tmpBaseDir })
+		expect(reloaded).toEqual(converted)
+
+		// Verify the file on disk is v2 format
+		const taskDir = path.join(tmpBaseDir, "tasks", taskId)
+		const raw = await fs.readFile(path.join(taskDir, "api_conversation_history.json"), "utf8")
+		const parsed = JSON.parse(raw)
+		expect(detectFormat(parsed)).toBe("v2")
+	})
+})

+ 126 - 0
src/core/task-persistence/apiMessages.ts

@@ -8,6 +8,9 @@ import { fileExistsAtPath } from "../../utils/fs"
 
 import { GlobalFileNames } from "../../shared/globalFileNames"
 import { getTaskDirectoryPath } from "../../utils/storage"
+import type { RooMessage, RooMessageHistory } from "./rooMessage"
+import { ROO_MESSAGE_VERSION } from "./rooMessage"
+import { convertAnthropicToRooMessages } from "./converters/anthropicToRoo"
 
 export type ApiMessage = Anthropic.MessageParam & {
 	ts?: number
@@ -119,3 +122,126 @@ export async function saveApiMessages({
 	const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
 	await safeWriteJson(filePath, messages)
 }
+
+// ────────────────────────────────────────────────────────────────────────────
+// RooMessage versioned storage
+// ────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Detect whether parsed JSON data is the new versioned RooMessage format
+ * or the legacy Anthropic array format.
+ */
+export function detectFormat(data: unknown): "v2" | "legacy" {
+	if (
+		data &&
+		typeof data === "object" &&
+		!Array.isArray(data) &&
+		"version" in data &&
+		(data as Record<string, unknown>).version === ROO_MESSAGE_VERSION &&
+		Array.isArray((data as Record<string, unknown>).messages)
+	) {
+		return "v2"
+	}
+	return "legacy"
+}
+
+/**
+ * Read a conversation history file and return `RooMessage[]`.
+ *
+ * - If the file is in v2 format (`{ version: 2, messages: [...] }`), the
+ *   messages are returned directly.
+ * - If the file is a plain array (legacy Anthropic format), the messages
+ *   are auto-converted via {@link convertAnthropicToRooMessages}.
+ * - Falls back to `claude_messages.json` when the primary file is missing.
+ */
+export async function readRooMessages({
+	taskId,
+	globalStoragePath,
+}: {
+	taskId: string
+	globalStoragePath: string
+}): Promise<RooMessage[]> {
+	const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
+	const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
+
+	const tryParseFile = async (targetPath: string): Promise<RooMessage[] | null> => {
+		if (!(await fileExistsAtPath(targetPath))) {
+			return null
+		}
+
+		const fileContent = await fs.readFile(targetPath, "utf8")
+		let parsedData: unknown
+
+		try {
+			parsedData = JSON.parse(fileContent)
+		} catch (error) {
+			console.warn(
+				`[readRooMessages] Error parsing file, returning empty. TaskId: ${taskId}, Path: ${targetPath}, Error: ${error}`,
+			)
+			return []
+		}
+
+		const format = detectFormat(parsedData)
+
+		if (format === "v2") {
+			return (parsedData as RooMessageHistory).messages
+		}
+
+		if (!Array.isArray(parsedData)) {
+			console.warn(
+				`[readRooMessages] Parsed data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${targetPath}`,
+			)
+			return []
+		}
+
+		return convertAnthropicToRooMessages(parsedData as ApiMessage[])
+	}
+
+	const primaryResult = await tryParseFile(filePath)
+	if (primaryResult !== null) {
+		return primaryResult
+	}
+
+	const oldPath = path.join(taskDir, "claude_messages.json")
+	const fallbackResult = await tryParseFile(oldPath)
+	if (fallbackResult !== null) {
+		return fallbackResult
+	}
+
+	console.error(
+		`[Roo-Debug] readRooMessages: API conversation history file not found for taskId: ${taskId}. Expected at: ${filePath}`,
+	)
+	return []
+}
+
+/**
+ * Save `RooMessage[]` wrapped in the versioned `RooMessageHistory` envelope.
+ *
+ * Always writes to `api_conversation_history.json` using {@link safeWriteJson}
+ * for atomic, corruption-resistant persistence.
+ *
+ * @returns `true` on success, `false` on failure.
+ */
+export async function saveRooMessages({
+	messages,
+	taskId,
+	globalStoragePath,
+}: {
+	messages: RooMessage[]
+	taskId: string
+	globalStoragePath: string
+}): Promise<boolean> {
+	try {
+		const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
+		const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
+		const envelope: RooMessageHistory = {
+			version: ROO_MESSAGE_VERSION,
+			messages,
+		}
+		await safeWriteJson(filePath, envelope)
+		return true
+	} catch (error) {
+		console.error(`[saveRooMessages] Failed to save messages for taskId: ${taskId}. Error: ${error}`)
+		return false
+	}
+}

+ 1075 - 0
src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts

@@ -0,0 +1,1075 @@
+import type { ApiMessage } from "../../apiMessages"
+import type {
+	RooUserMessage,
+	RooAssistantMessage,
+	RooToolMessage,
+	RooReasoningMessage,
+	TextPart,
+	ImagePart,
+	ToolCallPart,
+	ToolResultPart,
+	ReasoningPart,
+} from "../../rooMessage"
+import { convertAnthropicToRooMessages } from "../anthropicToRoo"
+
+// ────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ────────────────────────────────────────────────────────────────────────────
+
+/** Shorthand to create an ApiMessage with required fields. */
+function apiMsg(overrides: Partial<ApiMessage> & Pick<ApiMessage, "role" | "content">): ApiMessage {
+	return overrides as ApiMessage
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// 1. Simple string user/assistant messages
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("simple string messages", () => {
+	test("converts a simple string user message", () => {
+		const result = convertAnthropicToRooMessages([apiMsg({ role: "user", content: "Hello" })])
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooUserMessage
+		expect(msg.role).toBe("user")
+		expect(msg.content).toBe("Hello")
+	})
+
+	test("converts a simple string assistant message", () => {
+		const result = convertAnthropicToRooMessages([apiMsg({ role: "assistant", content: "Hi there" })])
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooAssistantMessage
+		expect(msg.role).toBe("assistant")
+		expect(msg.content).toBe("Hi there")
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 2. User messages with text content blocks
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("user messages with text content blocks", () => {
+	test("converts text content blocks to TextPart array", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "user",
+				content: [
+					{ type: "text", text: "First paragraph" },
+					{ type: "text", text: "Second paragraph" },
+				],
+			}),
+		])
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooUserMessage
+		expect(msg.role).toBe("user")
+		expect(Array.isArray(msg.content)).toBe(true)
+		const parts = msg.content as TextPart[]
+		expect(parts).toHaveLength(2)
+		expect(parts[0]).toEqual({ type: "text", text: "First paragraph" })
+		expect(parts[1]).toEqual({ type: "text", text: "Second paragraph" })
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 3. User messages with base64 image content
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("user messages with base64 image content", () => {
+	test("converts base64 image blocks to ImagePart", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "user",
+				content: [
+					{
+						type: "image",
+						source: {
+							type: "base64",
+							media_type: "image/png",
+							data: "iVBORw0KGgoAAAA==",
+						},
+					} as any,
+				],
+			}),
+		])
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooUserMessage
+		const parts = msg.content as ImagePart[]
+		expect(parts).toHaveLength(1)
+		expect(parts[0]).toEqual({
+			type: "image",
+			image: "data:image/png;base64,iVBORw0KGgoAAAA==",
+			mediaType: "image/png",
+		})
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 4. User messages with URL image content
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("user messages with URL image content", () => {
+	test("converts URL image blocks to ImagePart", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "user",
+				content: [
+					{
+						type: "image",
+						source: {
+							type: "url",
+							url: "https://example.com/image.png",
+						},
+					} as any,
+				],
+			}),
+		])
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooUserMessage
+		const parts = msg.content as ImagePart[]
+		expect(parts).toHaveLength(1)
+		expect(parts[0]).toEqual({
+			type: "image",
+			image: "https://example.com/image.png",
+		})
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 5. User messages with tool_result blocks → split into RooToolMessage + RooUserMessage
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("user messages with tool_result blocks", () => {
+	test("splits tool_result into RooToolMessage before RooUserMessage", () => {
+		const messages: ApiMessage[] = [
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "tool_use", id: "call_1", name: "read_file", input: { path: "foo.ts" } }],
+			}),
+			apiMsg({
+				role: "user",
+				content: [
+					{ type: "tool_result", tool_use_id: "call_1", content: "file contents here" },
+					{ type: "text", text: "Now please edit it" },
+				],
+			}),
+		]
+		const result = convertAnthropicToRooMessages(messages)
+
+		// assistant + tool + user = 3 messages
+		expect(result).toHaveLength(3)
+
+		// First: assistant with tool call
+		const assistantMsg = result[0] as RooAssistantMessage
+		expect(assistantMsg.role).toBe("assistant")
+
+		// Second: tool message with the result
+		const toolMsg = result[1] as RooToolMessage
+		expect(toolMsg.role).toBe("tool")
+		expect(toolMsg.content).toHaveLength(1)
+		expect(toolMsg.content[0]).toEqual({
+			type: "tool-result",
+			toolCallId: "call_1",
+			toolName: "read_file",
+			output: { type: "text", value: "file contents here" },
+		})
+
+		// Third: user message with remaining text
+		const userMsg = result[2] as RooUserMessage
+		expect(userMsg.role).toBe("user")
+		expect(userMsg.content).toEqual([{ type: "text", text: "Now please edit it" }])
+	})
+
+	test("handles tool_result with array content (joins text with newlines)", () => {
+		const messages: ApiMessage[] = [
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "tool_use", id: "call_2", name: "list_files", input: {} }],
+			}),
+			apiMsg({
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "call_2",
+						content: [
+							{ type: "text", text: "file1.ts" },
+							{ type: "text", text: "file2.ts" },
+						],
+					},
+				],
+			}),
+		]
+		const result = convertAnthropicToRooMessages(messages)
+		const toolMsg = result.find((m) => "role" in m && m.role === "tool") as RooToolMessage
+		expect(((toolMsg.content[0] as ToolResultPart).output as { value: string }).value).toBe("file1.ts\nfile2.ts")
+	})
+
+	test("handles tool_result with undefined content → (empty)", () => {
+		const messages: ApiMessage[] = [
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "tool_use", id: "call_3", name: "run_command", input: {} }],
+			}),
+			apiMsg({
+				role: "user",
+				content: [{ type: "tool_result", tool_use_id: "call_3", content: undefined as any }],
+			}),
+		]
+		const result = convertAnthropicToRooMessages(messages)
+		const toolMsg = result.find((m) => "role" in m && m.role === "tool") as RooToolMessage
+		expect(((toolMsg.content[0] as ToolResultPart).output as { value: string }).value).toBe("(empty)")
+	})
+
+	test("handles tool_result with empty string content → (empty)", () => {
+		const messages: ApiMessage[] = [
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "tool_use", id: "call_4", name: "run_command", input: {} }],
+			}),
+			apiMsg({
+				role: "user",
+				content: [{ type: "tool_result", tool_use_id: "call_4", content: "" }],
+			}),
+		]
+		const result = convertAnthropicToRooMessages(messages)
+		const toolMsg = result.find((m) => "role" in m && m.role === "tool") as RooToolMessage
+		expect(((toolMsg.content[0] as ToolResultPart).output as { value: string }).value).toBe("(empty)")
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 6. User messages with mixed tool_result and text
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("user messages with mixed tool_result and text", () => {
+	test("separates tool results from text/image parts correctly", () => {
+		const messages: ApiMessage[] = [
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "tool_use", id: "tc_a", name: "tool_a", input: {} },
+					{ type: "tool_use", id: "tc_b", name: "tool_b", input: {} },
+				],
+			}),
+			apiMsg({
+				role: "user",
+				content: [
+					{ type: "tool_result", tool_use_id: "tc_a", content: "result A" },
+					{ type: "text", text: "User commentary" },
+					{ type: "tool_result", tool_use_id: "tc_b", content: "result B" },
+					{
+						type: "image",
+						source: { type: "base64", media_type: "image/jpeg", data: "abc123" },
+					} as any,
+				],
+			}),
+		]
+		const result = convertAnthropicToRooMessages(messages)
+
+		// assistant + tool + user = 3
+		expect(result).toHaveLength(3)
+
+		const toolMsg = result[1] as RooToolMessage
+		expect(toolMsg.role).toBe("tool")
+		expect(toolMsg.content).toHaveLength(2)
+		expect((toolMsg.content[0] as ToolResultPart).toolCallId).toBe("tc_a")
+		expect((toolMsg.content[1] as ToolResultPart).toolCallId).toBe("tc_b")
+
+		const userMsg = result[2] as RooUserMessage
+		expect(userMsg.role).toBe("user")
+		const parts = userMsg.content as Array<TextPart | ImagePart>
+		expect(parts).toHaveLength(2)
+		expect(parts[0]).toEqual({ type: "text", text: "User commentary" })
+		expect(parts[1]).toEqual({
+			type: "image",
+			image: "data:image/jpeg;base64,abc123",
+			mediaType: "image/jpeg",
+		})
+	})
+
+	test("only emits tool message when no text/image parts exist", () => {
+		const messages: ApiMessage[] = [
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "tool_use", id: "tc_only", name: "some_tool", input: {} }],
+			}),
+			apiMsg({
+				role: "user",
+				content: [{ type: "tool_result", tool_use_id: "tc_only", content: "done" }],
+			}),
+		]
+		const result = convertAnthropicToRooMessages(messages)
+		// assistant + tool (no user message since no text/image parts)
+		expect(result).toHaveLength(2)
+		expect((result[1] as RooToolMessage).role).toBe("tool")
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 7. Assistant messages with text blocks
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("assistant messages with text blocks", () => {
+	test("converts text content blocks to TextPart array", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "text", text: "Here is my analysis:" },
+					{ type: "text", text: "The code looks good." },
+				],
+			}),
+		])
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooAssistantMessage
+		expect(msg.role).toBe("assistant")
+		const parts = msg.content as TextPart[]
+		expect(parts).toHaveLength(2)
+		expect(parts[0]).toEqual({ type: "text", text: "Here is my analysis:" })
+		expect(parts[1]).toEqual({ type: "text", text: "The code looks good." })
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 8. Assistant messages with tool_use blocks
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("assistant messages with tool_use blocks", () => {
+	test("converts tool_use blocks to ToolCallPart", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "text", text: "I'll read the file." },
+					{
+						type: "tool_use",
+						id: "toolu_01",
+						name: "read_file",
+						input: { path: "src/index.ts" },
+					},
+				],
+			}),
+		])
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooAssistantMessage
+		const parts = msg.content as Array<TextPart | ToolCallPart>
+		expect(parts).toHaveLength(2)
+		expect(parts[0]).toEqual({ type: "text", text: "I'll read the file." })
+		expect(parts[1]).toEqual({
+			type: "tool-call",
+			toolCallId: "toolu_01",
+			toolName: "read_file",
+			input: { path: "src/index.ts" },
+		})
+	})
+
+	test("converts multiple parallel tool_use blocks", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "tool_use", id: "tc1", name: "read_file", input: { path: "a.ts" } },
+					{ type: "tool_use", id: "tc2", name: "read_file", input: { path: "b.ts" } },
+				],
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		const parts = msg.content as ToolCallPart[]
+		expect(parts).toHaveLength(2)
+		expect(parts[0].toolCallId).toBe("tc1")
+		expect(parts[1].toolCallId).toBe("tc2")
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 9. Assistant messages with reasoning blocks (plain text)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("assistant messages with reasoning blocks", () => {
+	test("converts reasoning blocks to ReasoningPart", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "reasoning", text: "Let me think about this..." } as any,
+					{ type: "text", text: "The answer is 42." },
+				],
+			}),
+		])
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooAssistantMessage
+		const parts = msg.content as Array<ReasoningPart | TextPart>
+		expect(parts).toHaveLength(2)
+		expect(parts[0]).toEqual({ type: "reasoning", text: "Let me think about this..." })
+		expect(parts[1]).toEqual({ type: "text", text: "The answer is 42." })
+	})
+
+	test("skips reasoning blocks with empty text", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "reasoning", text: "" } as any, { type: "text", text: "Response" }],
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		const parts = msg.content as TextPart[]
+		expect(parts).toHaveLength(1)
+		expect(parts[0].type).toBe("text")
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 10. Assistant messages with thinking blocks (with signature)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("assistant messages with thinking blocks", () => {
+	test("converts thinking blocks to ReasoningPart with providerOptions containing signature", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [
+					{
+						type: "thinking",
+						thinking: "I need to carefully consider the edge cases...",
+						signature: "sig_abc123",
+					} as any,
+					{ type: "text", text: "Here's my response." },
+				],
+			}),
+		])
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooAssistantMessage
+		const parts = msg.content as Array<ReasoningPart | TextPart>
+		expect(parts).toHaveLength(2)
+		expect(parts[0]).toEqual({
+			type: "reasoning",
+			text: "I need to carefully consider the edge cases...",
+			providerOptions: {
+				bedrock: { signature: "sig_abc123" },
+				anthropic: { signature: "sig_abc123" },
+			},
+		})
+		expect(parts[1]).toEqual({ type: "text", text: "Here's my response." })
+	})
+
+	test("converts thinking blocks without signature", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "thinking", thinking: "Hmm let me think..." } as any,
+					{ type: "text", text: "Done." },
+				],
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		const parts = msg.content as Array<ReasoningPart | TextPart>
+		expect(parts[0]).toEqual({ type: "reasoning", text: "Hmm let me think..." })
+		expect(parts[0]).not.toHaveProperty("providerOptions")
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 11. Assistant messages with thoughtSignature blocks
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("assistant messages with thoughtSignature blocks", () => {
+	test("attaches thoughtSignature to first ToolCallPart via providerOptions", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "thoughtSignature", thoughtSignature: "gemini_sig_xyz" } as any,
+					{ type: "tool_use", id: "tc1", name: "read_file", input: { path: "a.ts" } },
+					{ type: "tool_use", id: "tc2", name: "write_file", input: { path: "b.ts" } },
+				],
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		const parts = msg.content as ToolCallPart[]
+		expect(parts).toHaveLength(2)
+
+		// First tool call gets the thoughtSignature
+		expect(parts[0].providerOptions).toEqual({
+			google: { thoughtSignature: "gemini_sig_xyz" },
+			vertex: { thoughtSignature: "gemini_sig_xyz" },
+		})
+
+		// Second tool call does NOT get the signature
+		expect(parts[1].providerOptions).toBeUndefined()
+	})
+
+	test("thoughtSignature block itself is not included in output parts", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "thoughtSignature", thoughtSignature: "sig123" } as any,
+					{ type: "text", text: "Response text" },
+				],
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		const parts = msg.content as TextPart[]
+		expect(parts).toHaveLength(1)
+		expect(parts[0]).toEqual({ type: "text", text: "Response text" })
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 12. Assistant messages with message-level reasoning_content
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("assistant messages with message-level reasoning_content", () => {
+	test("reasoning_content takes precedence over content-block reasoning", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "reasoning", text: "This should be skipped" } as any,
+					{ type: "text", text: "Final answer" },
+				],
+				reasoning_content: "DeepSeek canonical reasoning",
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		const parts = msg.content as Array<ReasoningPart | TextPart>
+		expect(parts).toHaveLength(2)
+		expect(parts[0]).toEqual({ type: "reasoning", text: "DeepSeek canonical reasoning" })
+		expect(parts[1]).toEqual({ type: "text", text: "Final answer" })
+	})
+
+	test("reasoning_content takes precedence over thinking blocks", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "thinking", thinking: "Skipped thinking", signature: "sig" } as any,
+					{ type: "text", text: "Answer" },
+				],
+				reasoning_content: "DeepSeek reasoning here",
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		const parts = msg.content as Array<ReasoningPart | TextPart>
+		expect(parts).toHaveLength(2)
+		expect(parts[0]).toEqual({ type: "reasoning", text: "DeepSeek reasoning here" })
+		expect(parts[0]).not.toHaveProperty("providerOptions")
+	})
+
+	test("empty reasoning_content is ignored", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "reasoning", text: "This should NOT be skipped" } as any,
+					{ type: "text", text: "Answer" },
+				],
+				reasoning_content: "",
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		const parts = msg.content as Array<ReasoningPart | TextPart>
+		expect(parts).toHaveLength(2)
+		expect(parts[0]).toEqual({ type: "reasoning", text: "This should NOT be skipped" })
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 13. Assistant messages with message-level reasoning_details
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("assistant messages with message-level reasoning_details", () => {
+	test("preserves valid reasoning_details via providerOptions", () => {
+		const details = [
+			{ type: "reasoning.encrypted", data: "encrypted_data_here" },
+			{ type: "reasoning.text", text: "Some reasoning text" },
+			{ type: "reasoning.summary", summary: "A summary" },
+		]
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "text", text: "Response" }],
+				reasoning_details: details,
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		expect(msg.providerOptions).toEqual({
+			openrouter: { reasoning_details: details },
+		})
+	})
+
+	test("filters out invalid reasoning_details entries", () => {
+		const details = [
+			{ type: "reasoning.encrypted", data: "" }, // invalid: empty data
+			{ type: "reasoning.encrypted" }, // invalid: missing data
+			{ type: "reasoning.text", text: "Valid text" }, // valid
+			{ type: "unknown_type" }, // invalid: unknown type
+		]
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "text", text: "Response" }],
+				reasoning_details: details,
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		expect(msg.providerOptions).toEqual({
+			openrouter: { reasoning_details: [{ type: "reasoning.text", text: "Valid text" }] },
+		})
+	})
+
+	test("does not set providerOptions when all reasoning_details are invalid", () => {
+		const details = [{ type: "reasoning.encrypted", data: "" }, { type: "bad_type" }]
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "text", text: "Response" }],
+				reasoning_details: details,
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		expect(msg.providerOptions).toBeUndefined()
+	})
+
+	test("preserves reasoning_details on string-content assistant messages", () => {
+		const details = [{ type: "reasoning.text", text: "Some reasoning" }]
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: "Simple string response",
+				reasoning_details: details,
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		expect(msg.content).toBe("Simple string response")
+		expect(msg.providerOptions).toEqual({
+			openrouter: { reasoning_details: details },
+		})
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 14. Standalone reasoning messages
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("standalone reasoning messages", () => {
+	test("converts standalone reasoning with encrypted_content", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: "",
+				type: "reasoning",
+				encrypted_content: "encrypted_data_blob",
+				id: "resp_001",
+				summary: [{ type: "summary_text", text: "I thought about X" }],
+			}),
+		])
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooReasoningMessage
+		expect(msg.type).toBe("reasoning")
+		expect(msg.encrypted_content).toBe("encrypted_data_blob")
+		expect(msg.id).toBe("resp_001")
+		expect(msg.summary).toEqual([{ type: "summary_text", text: "I thought about X" }])
+		expect(msg).not.toHaveProperty("role")
+	})
+
+	test("does not convert reasoning message without encrypted_content", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: "Some text",
+				type: "reasoning",
+			}),
+		])
+		// Without encrypted_content, falls through to normal assistant handling
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooAssistantMessage
+		expect(msg.role).toBe("assistant")
+		expect(msg.content).toBe("Some text")
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 15. Metadata preservation
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("metadata preservation", () => {
+	test("carries over all metadata fields on user messages", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "user",
+				content: "Hello",
+				ts: 1700000000000,
+				condenseId: "cond_1",
+				condenseParent: "cond_0",
+				truncationId: "trunc_1",
+				truncationParent: "trunc_0",
+				isTruncationMarker: true,
+				isSummary: true,
+			}),
+		])
+		const msg = result[0] as RooUserMessage
+		expect(msg.ts).toBe(1700000000000)
+		expect(msg.condenseId).toBe("cond_1")
+		expect(msg.condenseParent).toBe("cond_0")
+		expect(msg.truncationId).toBe("trunc_1")
+		expect(msg.truncationParent).toBe("trunc_0")
+		expect(msg.isTruncationMarker).toBe(true)
+		expect(msg.isSummary).toBe(true)
+	})
+
+	test("carries over metadata on assistant messages", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: "Response",
+				ts: 1700000001000,
+				isSummary: true,
+			}),
+		])
+		const msg = result[0] as RooAssistantMessage
+		expect(msg.ts).toBe(1700000001000)
+		expect(msg.isSummary).toBe(true)
+	})
+
+	test("carries over metadata on tool messages (split from user)", () => {
+		const messages: ApiMessage[] = [
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "tool_use", id: "tc_meta", name: "my_tool", input: {} }],
+			}),
+			apiMsg({
+				role: "user",
+				content: [{ type: "tool_result", tool_use_id: "tc_meta", content: "result" }],
+				ts: 1700000002000,
+				condenseId: "cond_2",
+			}),
+		]
+		const result = convertAnthropicToRooMessages(messages)
+		const toolMsg = result[1] as RooToolMessage
+		expect(toolMsg.role).toBe("tool")
+		expect(toolMsg.ts).toBe(1700000002000)
+		expect(toolMsg.condenseId).toBe("cond_2")
+	})
+
+	test("carries over metadata on standalone reasoning messages", () => {
+		const result = convertAnthropicToRooMessages([
+			apiMsg({
+				role: "assistant",
+				content: "",
+				type: "reasoning",
+				encrypted_content: "enc_data",
+				ts: 1700000003000,
+				truncationParent: "trunc_x",
+			}),
+		])
+		const msg = result[0] as RooReasoningMessage
+		expect(msg.ts).toBe(1700000003000)
+		expect(msg.truncationParent).toBe("trunc_x")
+	})
+
+	test("does not include undefined metadata fields", () => {
+		const result = convertAnthropicToRooMessages([apiMsg({ role: "user", content: "Hi" })])
+		const msg = result[0] as RooUserMessage
+		expect(msg).not.toHaveProperty("ts")
+		expect(msg).not.toHaveProperty("condenseId")
+		expect(msg).not.toHaveProperty("condenseParent")
+		expect(msg).not.toHaveProperty("truncationId")
+		expect(msg).not.toHaveProperty("truncationParent")
+		expect(msg).not.toHaveProperty("isTruncationMarker")
+		expect(msg).not.toHaveProperty("isSummary")
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 16. Tool name resolution via tool call ID map
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("tool name resolution", () => {
+	test("resolves tool names from preceding assistant messages", () => {
+		const messages: ApiMessage[] = [
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "tool_use", id: "tc_x", name: "execute_command", input: { command: "ls" } }],
+			}),
+			apiMsg({
+				role: "user",
+				content: [{ type: "tool_result", tool_use_id: "tc_x", content: "file_list" }],
+			}),
+		]
+		const result = convertAnthropicToRooMessages(messages)
+		const toolMsg = result[1] as RooToolMessage
+		expect((toolMsg.content[0] as ToolResultPart).toolName).toBe("execute_command")
+	})
+
+	test("falls back to unknown_tool when tool call ID is not found", () => {
+		const messages: ApiMessage[] = [
+			apiMsg({
+				role: "user",
+				content: [{ type: "tool_result", tool_use_id: "nonexistent_id", content: "result" }],
+			}),
+		]
+		const result = convertAnthropicToRooMessages(messages)
+		const toolMsg = result[0] as RooToolMessage
+		expect((toolMsg.content[0] as ToolResultPart).toolName).toBe("unknown_tool")
+	})
+
+	test("resolves tool names across multiple assistant messages", () => {
+		const messages: ApiMessage[] = [
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "tool_use", id: "tc_first", name: "tool_alpha", input: {} }],
+			}),
+			apiMsg({
+				role: "user",
+				content: [{ type: "tool_result", tool_use_id: "tc_first", content: "done" }],
+			}),
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "tool_use", id: "tc_second", name: "tool_beta", input: {} }],
+			}),
+			apiMsg({
+				role: "user",
+				content: [{ type: "tool_result", tool_use_id: "tc_second", content: "done" }],
+			}),
+		]
+		const result = convertAnthropicToRooMessages(messages)
+		const toolMsg1 = result[1] as RooToolMessage
+		const toolMsg2 = result[3] as RooToolMessage
+		expect((toolMsg1.content[0] as ToolResultPart).toolName).toBe("tool_alpha")
+		expect((toolMsg2.content[0] as ToolResultPart).toolName).toBe("tool_beta")
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 17. Empty/undefined content edge cases
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("empty/undefined content edge cases", () => {
+	test("handles empty string user message", () => {
+		const result = convertAnthropicToRooMessages([apiMsg({ role: "user", content: "" })])
+		expect(result).toHaveLength(1)
+		expect((result[0] as RooUserMessage).content).toBe("")
+	})
+
+	test("handles empty string assistant message", () => {
+		const result = convertAnthropicToRooMessages([apiMsg({ role: "assistant", content: "" })])
+		expect(result).toHaveLength(1)
+		expect((result[0] as RooAssistantMessage).content).toBe("")
+	})
+
+	test("handles empty array content for user (no output messages from that input)", () => {
+		const result = convertAnthropicToRooMessages([apiMsg({ role: "user", content: [] })])
+		// No text/image parts and no tool results → no messages emitted
+		expect(result).toHaveLength(0)
+	})
+
+	test("handles empty array content for assistant", () => {
+		const result = convertAnthropicToRooMessages([apiMsg({ role: "assistant", content: [] })])
+		expect(result).toHaveLength(1)
+		const msg = result[0] as RooAssistantMessage
+		// Empty content array falls back to empty string
+		expect(msg.content).toBe("")
+	})
+
+	test("handles empty messages array input", () => {
+		const result = convertAnthropicToRooMessages([])
+		expect(result).toHaveLength(0)
+	})
+
+	test("handles tool_result with image content blocks", () => {
+		const messages: ApiMessage[] = [
+			apiMsg({
+				role: "assistant",
+				content: [{ type: "tool_use", id: "tc_img", name: "screenshot", input: {} }],
+			}),
+			apiMsg({
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "tc_img",
+						content: [
+							{ type: "text", text: "Screenshot taken" },
+							{
+								type: "image",
+								source: { type: "base64", media_type: "image/png", data: "img_data" },
+							},
+						],
+					},
+				],
+			}),
+		]
+		const result = convertAnthropicToRooMessages(messages)
+		const toolMsg = result[1] as RooToolMessage
+		expect(((toolMsg.content[0] as ToolResultPart).output as { value: string }).value).toBe(
+			"Screenshot taken\n(image)",
+		)
+	})
+})
+
+// ────────────────────────────────────────────────────────────────────────────
+// 18. Full conversation round-trip (multi-message sequence)
+// ────────────────────────────────────────────────────────────────────────────
+
+describe("full conversation round-trip", () => {
+	test("converts a realistic multi-turn conversation", () => {
+		const messages: ApiMessage[] = [
+			// Turn 1: user asks a question
+			apiMsg({ role: "user", content: "Can you read my config file?", ts: 1000 }),
+			// Turn 2: assistant uses a tool
+			apiMsg({
+				role: "assistant",
+				content: [
+					{ type: "text", text: "Sure, let me read it." },
+					{
+						type: "tool_use",
+						id: "toolu_read",
+						name: "read_file",
+						input: { path: "config.json" },
+					},
+				],
+				ts: 2000,
+			}),
+			// Turn 3: tool result + user follow-up
+			apiMsg({
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "toolu_read",
+						content: '{"port": 3000}',
+					},
+					{ type: "text", text: "Can you change the port to 8080?" },
+				],
+				ts: 3000,
+			}),
+			// Turn 4: assistant with thinking + tool use
+			apiMsg({
+				role: "assistant",
+				content: [
+					{
+						type: "thinking",
+						thinking: "I need to modify the port value...",
+						signature: "sig_think_1",
+					} as any,
+					{ type: "text", text: "I'll update the port for you." },
+					{
+						type: "tool_use",
+						id: "toolu_write",
+						name: "write_file",
+						input: { path: "config.json", content: '{"port": 8080}' },
+					},
+				],
+				ts: 4000,
+			}),
+			// Turn 5: tool result
+			apiMsg({
+				role: "user",
+				content: [{ type: "tool_result", tool_use_id: "toolu_write", content: "File written successfully" }],
+				ts: 5000,
+			}),
+			// Turn 6: assistant confirms
+			apiMsg({ role: "assistant", content: "Done! The port has been updated to 8080.", ts: 6000 }),
+			// Turn 7: standalone reasoning
+			apiMsg({
+				role: "assistant",
+				content: "",
+				type: "reasoning",
+				encrypted_content: "enc_reasoning_blob",
+				id: "resp_reason",
+				ts: 6500,
+			}),
+		]
+
+		const result = convertAnthropicToRooMessages(messages)
+
+		// Expected sequence:
+		// 0: user "Can you read my config file?"
+		// 1: assistant [text + tool_use]
+		// 2: tool [result of toolu_read]
+		// 3: user [text: "Can you change the port..."]
+		// 4: assistant [thinking + text + tool_use]
+		// 5: tool [result of toolu_write]
+		// 6: assistant "Done! The port has been updated..."
+		// 7: reasoning message
+
+		expect(result).toHaveLength(8)
+
+		// Message 0: user string
+		const m0 = result[0] as RooUserMessage
+		expect(m0.role).toBe("user")
+		expect(m0.content).toBe("Can you read my config file?")
+		expect(m0.ts).toBe(1000)
+
+		// Message 1: assistant with text + tool call
+		const m1 = result[1] as RooAssistantMessage
+		expect(m1.role).toBe("assistant")
+		expect(m1.ts).toBe(2000)
+		const m1Parts = m1.content as Array<TextPart | ToolCallPart>
+		expect(m1Parts).toHaveLength(2)
+		expect(m1Parts[0]).toEqual({ type: "text", text: "Sure, let me read it." })
+		expect(m1Parts[1]).toMatchObject({
+			type: "tool-call",
+			toolCallId: "toolu_read",
+			toolName: "read_file",
+		})
+
+		// Message 2: tool result
+		const m2 = result[2] as RooToolMessage
+		expect(m2.role).toBe("tool")
+		expect(m2.ts).toBe(3000)
+		expect(m2.content[0]).toMatchObject({
+			type: "tool-result",
+			toolCallId: "toolu_read",
+			toolName: "read_file",
+			output: { type: "text", value: '{"port": 3000}' },
+		})
+
+		// Message 3: user follow-up text
+		const m3 = result[3] as RooUserMessage
+		expect(m3.role).toBe("user")
+		expect(m3.ts).toBe(3000)
+		expect(m3.content).toEqual([{ type: "text", text: "Can you change the port to 8080?" }])
+
+		// Message 4: assistant with thinking + text + tool call
+		const m4 = result[4] as RooAssistantMessage
+		expect(m4.role).toBe("assistant")
+		expect(m4.ts).toBe(4000)
+		const m4Parts = m4.content as Array<ReasoningPart | TextPart | ToolCallPart>
+		expect(m4Parts).toHaveLength(3)
+		expect(m4Parts[0]).toEqual({
+			type: "reasoning",
+			text: "I need to modify the port value...",
+			providerOptions: {
+				bedrock: { signature: "sig_think_1" },
+				anthropic: { signature: "sig_think_1" },
+			},
+		})
+		expect(m4Parts[1]).toEqual({ type: "text", text: "I'll update the port for you." })
+		expect(m4Parts[2]).toMatchObject({
+			type: "tool-call",
+			toolCallId: "toolu_write",
+			toolName: "write_file",
+		})
+
+		// Message 5: tool result
+		const m5 = result[5] as RooToolMessage
+		expect(m5.role).toBe("tool")
+		expect(m5.ts).toBe(5000)
+		expect(((m5.content[0] as ToolResultPart).output as { value: string }).value).toBe("File written successfully")
+
+		// Message 6: assistant string
+		const m6 = result[6] as RooAssistantMessage
+		expect(m6.role).toBe("assistant")
+		expect(m6.content).toBe("Done! The port has been updated to 8080.")
+		expect(m6.ts).toBe(6000)
+
+		// Message 7: standalone reasoning
+		const m7 = result[7] as RooReasoningMessage
+		expect(m7.type).toBe("reasoning")
+		expect(m7.encrypted_content).toBe("enc_reasoning_blob")
+		expect(m7.id).toBe("resp_reason")
+		expect(m7.ts).toBe(6500)
+	})
+})

+ 308 - 0
src/core/task-persistence/converters/anthropicToRoo.ts

@@ -0,0 +1,308 @@
+/**
+ * Converter from Anthropic-format `ApiMessage` to the new `RooMessage` format.
+ *
+ * This is the critical backward-compatibility piece that allows old conversation
+ * histories stored in Anthropic format to be read and converted to the new format.
+ *
+ * The conversion logic mirrors {@link ../../api/transform/ai-sdk.ts | convertToAiSdkMessages}
+ * but targets `RooMessage` types instead of AI SDK `ModelMessage`.
+ */
+
+import type { TextPart, ImagePart, ToolCallPart, ToolResultPart, ReasoningPart } from "../rooMessage"
+import type { ApiMessage } from "../apiMessages"
+import type {
+	RooMessage,
+	RooUserMessage,
+	RooAssistantMessage,
+	RooToolMessage,
+	RooReasoningMessage,
+	RooMessageMetadata,
+} from "../rooMessage"
+
+/**
+ * Loose providerOptions shape used internally during message construction.
+ * The AI SDK's `ProviderOptions` requires `Record<string, JSONObject>`, but our
+ * intermediate data (e.g. reasoning_details) is typed more loosely. We cast to
+ * this type during construction and let the AI SDK handle validation downstream.
+ */
+type LooseProviderOptions = Record<string, Record<string, unknown>>
+
+/**
+ * Extract Roo-specific metadata fields from an ApiMessage.
+ * Only includes fields that are actually defined (avoids `undefined` keys).
+ */
+function extractMetadata(message: ApiMessage): RooMessageMetadata {
+	const metadata: RooMessageMetadata = {}
+	if (message.ts !== undefined) metadata.ts = message.ts
+	if (message.condenseId !== undefined) metadata.condenseId = message.condenseId
+	if (message.condenseParent !== undefined) metadata.condenseParent = message.condenseParent
+	if (message.truncationId !== undefined) metadata.truncationId = message.truncationId
+	if (message.truncationParent !== undefined) metadata.truncationParent = message.truncationParent
+	if (message.isTruncationMarker !== undefined) metadata.isTruncationMarker = message.isTruncationMarker
+	if (message.isSummary !== undefined) metadata.isSummary = message.isSummary
+	return metadata
+}
+
+/**
+ * Validate and filter reasoning_details entries for OpenRouter round-tripping.
+ * Invalid entries are filtered out to prevent downstream parse failures.
+ */
+function filterValidReasoningDetails(details: Record<string, unknown>[]): Record<string, unknown>[] {
+	return details.filter((detail) => {
+		switch (detail.type) {
+			case "reasoning.encrypted":
+				return typeof detail.data === "string" && detail.data.length > 0
+			case "reasoning.text":
+				return typeof detail.text === "string"
+			case "reasoning.summary":
+				return typeof detail.summary === "string"
+			default:
+				return false
+		}
+	})
+}
+
+/**
+ * Attach OpenRouter reasoning_details as providerOptions on an assistant message
+ * if they are present and valid.
+ */
+function attachReasoningDetails(
+	assistantMsg: RooAssistantMessage,
+	rawDetails: Record<string, unknown>[] | undefined,
+): void {
+	if (!rawDetails || rawDetails.length === 0) return
+	const valid = filterValidReasoningDetails(rawDetails)
+	if (valid.length > 0) {
+		const opts: LooseProviderOptions = {
+			...((assistantMsg.providerOptions as LooseProviderOptions | undefined) ?? {}),
+			openrouter: { reasoning_details: valid },
+		}
+		;(assistantMsg as { providerOptions?: LooseProviderOptions }).providerOptions = opts
+	}
+}
+
+/**
+ * Convert an array of Anthropic-format `ApiMessage` objects to `RooMessage` format.
+ *
+ * Conversion rules:
+ * - User string content → `RooUserMessage` with `content: string`
+ * - User array content → text/image parts stay in `RooUserMessage`, tool_result blocks
+ *   are split into a separate `RooToolMessage`
+ * - Assistant string content → `RooAssistantMessage` with `content: string`
+ * - Assistant array content → text, tool-call, and reasoning parts in `RooAssistantMessage`
+ * - Standalone reasoning messages → `RooReasoningMessage`
+ * - Metadata fields (ts, condenseId, etc.) are preserved on all output messages
+ *
+ * @param messages - Array of ApiMessage (Anthropic format with metadata)
+ * @returns Array of RooMessage objects
+ */
+export function convertAnthropicToRooMessages(messages: ApiMessage[]): RooMessage[] {
+	const result: RooMessage[] = []
+
+	// First pass: build a map of tool call IDs to tool names from assistant messages.
+	// This is needed to resolve tool names for tool_result blocks in user messages.
+	const toolCallIdToName = new Map<string, string>()
+	for (const message of messages) {
+		if (message.role === "assistant" && typeof message.content !== "string") {
+			for (const part of message.content) {
+				if (part.type === "tool_use") {
+					toolCallIdToName.set(part.id, part.name)
+				}
+			}
+		}
+	}
+
+	for (const message of messages) {
+		const metadata = extractMetadata(message)
+
+		// ── Standalone reasoning messages ──────────────────────────────────
+		if (message.type === "reasoning" && message.encrypted_content) {
+			const reasoningMsg: RooReasoningMessage = {
+				type: "reasoning",
+				encrypted_content: message.encrypted_content,
+				...metadata,
+			}
+			if (message.id) reasoningMsg.id = message.id
+			if (message.summary) reasoningMsg.summary = message.summary
+			result.push(reasoningMsg)
+			continue
+		}
+
+		// ── String content (both user and assistant) ──────────────────────
+		if (typeof message.content === "string") {
+			if (message.role === "user") {
+				result.push({ role: "user", content: message.content, ...metadata } as RooUserMessage)
+			} else if (message.role === "assistant") {
+				const assistantMsg: RooAssistantMessage = {
+					role: "assistant",
+					content: message.content,
+					...metadata,
+				}
+				attachReasoningDetails(assistantMsg, message.reasoning_details as Record<string, unknown>[] | undefined)
+				result.push(assistantMsg)
+			}
+			continue
+		}
+
+		// ── Array content: User messages ──────────────────────────────────
+		if (message.role === "user") {
+			const parts: Array<TextPart | ImagePart> = []
+			const toolResults: ToolResultPart[] = []
+
+			for (const part of message.content) {
+				if (part.type === "text") {
+					parts.push({ type: "text", text: part.text })
+				} else if (part.type === "image") {
+					const source = part.source as {
+						type: string
+						media_type?: string
+						data?: string
+						url?: string
+					}
+					if (source.type === "base64" && source.media_type && source.data) {
+						parts.push({
+							type: "image",
+							image: `data:${source.media_type};base64,${source.data}`,
+							mediaType: source.media_type,
+						})
+					} else if (source.type === "url" && source.url) {
+						parts.push({
+							type: "image",
+							image: source.url,
+						})
+					}
+				} else if (part.type === "tool_result") {
+					let content: string
+					if (typeof part.content === "string") {
+						content = part.content
+					} else {
+						content =
+							part.content
+								?.map((c) => {
+									if (c.type === "text") return c.text
+									if (c.type === "image") return "(image)"
+									return ""
+								})
+								.join("\n") ?? ""
+					}
+					const toolName = toolCallIdToName.get(part.tool_use_id) ?? "unknown_tool"
+					toolResults.push({
+						type: "tool-result",
+						toolCallId: part.tool_use_id,
+						toolName,
+						output: { type: "text", value: content || "(empty)" },
+					})
+				}
+			}
+
+			// Tool results go into a separate RooToolMessage (emitted before user content)
+			if (toolResults.length > 0) {
+				result.push({ role: "tool", content: toolResults, ...metadata } as RooToolMessage)
+			}
+
+			// Text/image parts stay in RooUserMessage
+			if (parts.length > 0) {
+				result.push({ role: "user", content: parts, ...metadata } as RooUserMessage)
+			}
+			continue
+		}
+
+		// ── Array content: Assistant messages ─────────────────────────────
+		if (message.role === "assistant") {
+			// Check for message-level reasoning_content (DeepSeek interleaved thinking).
+			// When present, it takes precedence over content-block reasoning/thinking.
+			const reasoningContent = (() => {
+				const maybe = message.reasoning_content
+				return typeof maybe === "string" && maybe.length > 0 ? maybe : undefined
+			})()
+
+			const content: Array<TextPart | ToolCallPart | ReasoningPart> = []
+
+			// Extract thoughtSignature from content blocks (Gemini 3 thought signature).
+			let thoughtSignature: string | undefined
+			for (const part of message.content) {
+				const partAny = part as unknown as { type?: string; thoughtSignature?: string }
+				if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) {
+					thoughtSignature = partAny.thoughtSignature
+				}
+			}
+
+			// If message-level reasoning_content exists, add it as the canonical reasoning part
+			if (reasoningContent) {
+				content.push({ type: "reasoning", text: reasoningContent })
+			}
+
+			let toolCallCount = 0
+			for (const part of message.content) {
+				if (part.type === "text") {
+					content.push({ type: "text", text: part.text })
+					continue
+				}
+
+				if (part.type === "tool_use") {
+					const toolCall: ToolCallPart = {
+						type: "tool-call",
+						toolCallId: part.id,
+						toolName: part.name,
+						input: part.input,
+					}
+					// Attach thoughtSignature on the first tool call only (Gemini 3 rule)
+					if (thoughtSignature && toolCallCount === 0) {
+						toolCall.providerOptions = {
+							google: { thoughtSignature },
+							vertex: { thoughtSignature },
+						} as ToolCallPart["providerOptions"]
+					}
+					toolCallCount++
+					content.push(toolCall)
+					continue
+				}
+
+				const partAny = part as unknown as Record<string, unknown>
+
+				// Skip thoughtSignature blocks (already extracted above)
+				if (partAny.type === "thoughtSignature") continue
+
+				// Reasoning blocks (type: "reasoning" with text field)
+				if (partAny.type === "reasoning") {
+					if (reasoningContent) continue
+					if (typeof partAny.text === "string" && (partAny.text as string).length > 0) {
+						content.push({ type: "reasoning", text: partAny.text as string })
+					}
+					continue
+				}
+
+				// Thinking blocks (type: "thinking" with thinking and signature)
+				if (partAny.type === "thinking") {
+					if (reasoningContent) continue
+					if (typeof partAny.thinking === "string" && (partAny.thinking as string).length > 0) {
+						const reasoningPart: ReasoningPart = {
+							type: "reasoning",
+							text: partAny.thinking as string,
+						}
+						if (partAny.signature) {
+							reasoningPart.providerOptions = {
+								bedrock: { signature: partAny.signature as string },
+								anthropic: { signature: partAny.signature as string },
+							} as ReasoningPart["providerOptions"]
+						}
+						content.push(reasoningPart)
+					}
+					continue
+				}
+			}
+
+			const assistantMsg: RooAssistantMessage = {
+				role: "assistant",
+				content: content.length > 0 ? content : "",
+				...metadata,
+			}
+
+			attachReasoningDetails(assistantMsg, message.reasoning_details as Record<string, unknown>[] | undefined)
+
+			result.push(assistantMsg)
+		}
+	}
+
+	return result
+}

+ 7 - 0
src/core/task-persistence/index.ts

@@ -1,3 +1,10 @@
 export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages"
+export { detectFormat, readRooMessages, saveRooMessages } from "./apiMessages"
 export { readTaskMessages, saveTaskMessages } from "./taskMessages"
 export { taskMetadata } from "./taskMetadata"
+export type { RooMessage, RooMessageHistory, RooMessageMetadata } from "./rooMessage"
+export type { RooUserMessage, RooAssistantMessage, RooToolMessage, RooReasoningMessage } from "./rooMessage"
+export { isRooUserMessage, isRooAssistantMessage, isRooToolMessage, isRooReasoningMessage } from "./rooMessage"
+export type { TextPart, ImagePart, FilePart, ToolCallPart, ToolResultPart, ReasoningPart } from "./rooMessage"
+export { convertAnthropicToRooMessages } from "./converters/anthropicToRoo"
+export { flattenModelMessagesToStringContent } from "./messageUtils"

+ 69 - 0
src/core/task-persistence/messageUtils.ts

@@ -0,0 +1,69 @@
+/**
+ * Utility functions for transforming `ModelMessage` arrays.
+ *
+ * These operate on `ModelMessage[]`, which means they also accept `RooMessage[]`
+ * thanks to TypeScript's structural typing (RooMessage extends ModelMessage with metadata).
+ */
+
+import type { ModelMessage } from "ai"
+
+/**
+ * Options for flattening ModelMessage content arrays to plain strings.
+ */
+export interface FlattenMessagesOptions {
+	/**
+	 * If true, flattens user messages with only text parts to string content.
+	 * Default: true
+	 */
+	flattenUserMessages?: boolean
+	/**
+	 * If true, flattens assistant messages with only text (no tool calls) to string content.
+	 * Default: true
+	 */
+	flattenAssistantMessages?: boolean
+}
+
+/**
+ * Flatten `ModelMessage` content arrays to plain string content where possible.
+ *
+ * Used by providers (e.g., DeepSeek on SambaNova) that require string content
+ * instead of array content. Only flattens messages whose content parts are all
+ * text (or text + reasoning for assistant messages).
+ *
+ * @param messages - Array of ModelMessage objects
+ * @param options - Controls which message roles to flatten
+ * @returns New array of ModelMessage objects with flattened content where applicable
+ */
+export function flattenModelMessagesToStringContent(
+	messages: ModelMessage[],
+	options: FlattenMessagesOptions = {},
+): ModelMessage[] {
+	const { flattenUserMessages = true, flattenAssistantMessages = true } = options
+
+	return messages.map((message) => {
+		if (typeof message.content === "string") {
+			return message
+		}
+
+		if (message.role === "user" && flattenUserMessages && Array.isArray(message.content)) {
+			const parts = message.content as Array<{ type: string; text?: string }>
+			const allText = parts.every((part) => part.type === "text")
+			if (allText && parts.length > 0) {
+				const textContent = parts.map((part) => part.text || "").join("\n")
+				return { ...message, content: textContent }
+			}
+		}
+
+		if (message.role === "assistant" && flattenAssistantMessages && Array.isArray(message.content)) {
+			const parts = message.content as Array<{ type: string; text?: string }>
+			const allTextOrReasoning = parts.every((part) => part.type === "text" || part.type === "reasoning")
+			if (allTextOrReasoning && parts.length > 0) {
+				const textParts = parts.filter((part) => part.type === "text")
+				const textContent = textParts.map((part) => part.text || "").join("\n")
+				return { ...message, content: textContent }
+			}
+		}
+
+		return message
+	})
+}

+ 152 - 0
src/core/task-persistence/rooMessage.ts

@@ -0,0 +1,152 @@
+/**
+ * RooMessage Type System
+ *
+ * This module defines the internal message storage format using AI SDK types directly.
+ * Message types extend the AI SDK's `ModelMessage` variants with Roo-specific metadata,
+ * and content part types (`TextPart`, `ImagePart`, etc.) are re-exported from the AI SDK.
+ *
+ * @see {@link ../../plans/ext-646-modelmessage-schema-migration-strategy.md} for full migration context
+ */
+
+import type { UserModelMessage, AssistantModelMessage, ToolModelMessage, AssistantContent } from "ai"
+
+// Re-export AI SDK content part types for convenience
+export type { TextPart, ImagePart, FilePart, ToolCallPart, ToolResultPart } from "ai"
+
+/**
+ * `ReasoningPart` is used by the AI SDK in `AssistantContent` but is not directly
+ * exported from `"ai"`. We extract it from the `AssistantContent` union to get the
+ * exact same type without adding a dependency on `@ai-sdk/provider-utils`.
+ */
+type AssistantContentPart = Exclude<AssistantContent, string>[number]
+export type ReasoningPart = Extract<AssistantContentPart, { type: "reasoning" }>
+
+// ────────────────────────────────────────────────────────────────────────────
+// Version
+// ────────────────────────────────────────────────────────────────────────────
+
+/** Current format version for the RooMessage storage schema. */
+export const ROO_MESSAGE_VERSION = 2 as const
+
+// ────────────────────────────────────────────────────────────────────────────
+// Metadata
+// ────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Metadata fields shared across all RooMessage types.
+ * These are Roo-specific extensions that do not exist in the AI SDK types.
+ */
+export interface RooMessageMetadata {
+	/** Unix timestamp (ms) when the message was created. */
+	ts?: number
+	/** Unique identifier for non-destructive condense summary messages. */
+	condenseId?: string
+	/** Points to the `condenseId` of the summary that replaces this message. */
+	condenseParent?: string
+	/** Unique identifier for non-destructive truncation marker messages. */
+	truncationId?: string
+	/** Points to the `truncationId` of the marker that hides this message. */
+	truncationParent?: string
+	/** Identifies this message as a truncation boundary marker. */
+	isTruncationMarker?: boolean
+	/** Identifies this message as a condense summary. */
+	isSummary?: boolean
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// Message Types
+// ────────────────────────────────────────────────────────────────────────────
+
+/**
+ * A user-authored message. Content may be a plain string or an array of
+ * text, image, and file parts. Extends AI SDK `UserModelMessage` with metadata.
+ */
+export type RooUserMessage = UserModelMessage & RooMessageMetadata
+
+/**
+ * An assistant-authored message. Content may be a plain string or an array of
+ * text, tool-call, and reasoning parts. Extends AI SDK `AssistantModelMessage`
+ * with metadata and a provider response ID.
+ */
+export type RooAssistantMessage = AssistantModelMessage &
+	RooMessageMetadata & {
+		/** Provider response ID (e.g. OpenAI `response.id`). */
+		id?: string
+	}
+
+/**
+ * A tool result message containing one or more tool outputs.
+ * Extends AI SDK `ToolModelMessage` with metadata.
+ */
+export type RooToolMessage = ToolModelMessage & RooMessageMetadata
+
+/**
+ * A standalone encrypted reasoning item (e.g. OpenAI Native reasoning format).
+ * These are stored as top-level items in the message history, not nested
+ * inside an assistant message's content array.
+ * This has no AI SDK equivalent.
+ */
+export interface RooReasoningMessage extends RooMessageMetadata {
+	type: "reasoning"
+	/** Encrypted reasoning content from the provider. */
+	encrypted_content: string
+	/** Provider response ID. */
+	id?: string
+	/** Summary of the reasoning, if provided by the model. */
+	summary?: Array<{ type: string; text: string }>
+}
+
+/**
+ * Union of all message types that can appear in a Roo conversation history.
+ */
+export type RooMessage = RooUserMessage | RooAssistantMessage | RooToolMessage | RooReasoningMessage
+
+// ────────────────────────────────────────────────────────────────────────────
+// Storage Wrapper
+// ────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Versioned wrapper for persisted message history.
+ * The `version` field enables forward-compatible schema migrations.
+ */
+export interface RooMessageHistory {
+	version: 2
+	messages: RooMessage[]
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// Type Guards
+// ────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Type guard that checks whether a message is a {@link RooUserMessage}.
+ * Matches objects with `role === "user"`.
+ */
+export function isRooUserMessage(msg: RooMessage): msg is RooUserMessage {
+	return "role" in msg && msg.role === "user"
+}
+
+/**
+ * Type guard that checks whether a message is a {@link RooAssistantMessage}.
+ * Matches objects with `role === "assistant"`.
+ */
+export function isRooAssistantMessage(msg: RooMessage): msg is RooAssistantMessage {
+	return "role" in msg && msg.role === "assistant"
+}
+
+/**
+ * Type guard that checks whether a message is a {@link RooToolMessage}.
+ * Matches objects with `role === "tool"`.
+ */
+export function isRooToolMessage(msg: RooMessage): msg is RooToolMessage {
+	return "role" in msg && msg.role === "tool"
+}
+
+/**
+ * Type guard that checks whether a message is a {@link RooReasoningMessage}.
+ * Matches objects with `type === "reasoning"` and no `role` property,
+ * distinguishing it from reasoning content parts or assistant messages.
+ */
+export function isRooReasoningMessage(msg: RooMessage): msg is RooReasoningMessage {
+	return "type" in msg && (msg as RooReasoningMessage).type === "reasoning" && !("role" in msg)
+}