From f59ffad0af3562422adde37dab878fbd866db024 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 10 Feb 2026 15:03:49 -0500 Subject: [PATCH 1/3] feat: add RooMessage type system and storage layer for ModelMessage migration Add foundation infrastructure for migrating message storage from Anthropic format to AI SDK ModelMessage format (EXT-646). - RooMessage type = AI SDK ModelMessage + Roo metadata (ts, condense, truncation) - Anthropic-to-RooMessage converter for migrating old conversations on read - Versioned storage (readRooMessages/saveRooMessages) with auto-detection - flattenModelMessagesToStringContent utility for providers needing string content No behavior change - purely additive. Existing code paths untouched. EXT-647 will wire Task.ts to use these new functions. --- .../__tests__/messageUtils.spec.ts | 115 ++ .../__tests__/rooMessage.spec.ts | 229 ++++ .../__tests__/rooMessages.spec.ts | 270 +++++ src/core/task-persistence/apiMessages.ts | 126 ++ .../__tests__/anthropicToRoo.spec.ts | 1075 +++++++++++++++++ .../converters/anthropicToRoo.ts | 308 +++++ src/core/task-persistence/converters/index.ts | 1 + src/core/task-persistence/index.ts | 8 + src/core/task-persistence/messageUtils.ts | 69 ++ src/core/task-persistence/rooMessage.ts | 161 +++ 10 files changed, 2362 insertions(+) create mode 100644 src/core/task-persistence/__tests__/messageUtils.spec.ts create mode 100644 src/core/task-persistence/__tests__/rooMessage.spec.ts create mode 100644 src/core/task-persistence/__tests__/rooMessages.spec.ts create mode 100644 src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts create mode 100644 src/core/task-persistence/converters/anthropicToRoo.ts create mode 100644 src/core/task-persistence/converters/index.ts create mode 100644 src/core/task-persistence/messageUtils.ts create mode 100644 src/core/task-persistence/rooMessage.ts diff --git a/src/core/task-persistence/__tests__/messageUtils.spec.ts b/src/core/task-persistence/__tests__/messageUtils.spec.ts new file mode 100644 index 00000000000..7a0a1d2b947 --- /dev/null +++ b/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) + }) +}) diff --git a/src/core/task-persistence/__tests__/rooMessage.spec.ts b/src/core/task-persistence/__tests__/rooMessage.spec.ts new file mode 100644 index 00000000000..5e5d52621b3 --- /dev/null +++ b/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) + }) +}) diff --git a/src/core/task-persistence/__tests__/rooMessages.spec.ts b/src/core/task-persistence/__tests__/rooMessages.spec.ts new file mode 100644 index 00000000000..9c9c8b22810 --- /dev/null +++ b/src/core/task-persistence/__tests__/rooMessages.spec.ts @@ -0,0 +1,270 @@ +// 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" + +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 { + 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", "<<>>") + + 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(() => {}) + + // Use a path that will fail (no permission or invalid) + const success = await saveRooMessages({ + messages: sampleRooMessages, + taskId: "task-fail", + globalStoragePath: "/nonexistent/readonly/path", + }) + + 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") + }) +}) diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 7672f6f7ee6..3c2149ed44e 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/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).version === ROO_MESSAGE_VERSION && + Array.isArray((data as Record).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 { + const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) + const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory) + + const tryParseFile = async (targetPath: string): Promise => { + 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 { + 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 + } +} diff --git a/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts b/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts new file mode 100644 index 00000000000..8cfc72a9e8e --- /dev/null +++ b/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 & Pick): 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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) + }) +}) diff --git a/src/core/task-persistence/converters/anthropicToRoo.ts b/src/core/task-persistence/converters/anthropicToRoo.ts new file mode 100644 index 00000000000..05942e219d6 --- /dev/null +++ b/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`, 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> + +/** + * 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[]): Record[] { + 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[] | 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() + 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[] | undefined) + result.push(assistantMsg) + } + continue + } + + // ── Array content: User messages ────────────────────────────────── + if (message.role === "user") { + const parts: Array = [] + 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 = [] + + // 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 + + // 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[] | undefined) + + result.push(assistantMsg) + } + } + + return result +} diff --git a/src/core/task-persistence/converters/index.ts b/src/core/task-persistence/converters/index.ts new file mode 100644 index 00000000000..3e963ace79c --- /dev/null +++ b/src/core/task-persistence/converters/index.ts @@ -0,0 +1 @@ +export { convertAnthropicToRooMessages } from "./anthropicToRoo" diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index c8656002bde..6bc2fbc6ce6 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -1,3 +1,11 @@ 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" +export type { FlattenMessagesOptions } from "./messageUtils" diff --git a/src/core/task-persistence/messageUtils.ts b/src/core/task-persistence/messageUtils.ts new file mode 100644 index 00000000000..566fec51039 --- /dev/null +++ b/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 + }) +} diff --git a/src/core/task-persistence/rooMessage.ts b/src/core/task-persistence/rooMessage.ts new file mode 100644 index 00000000000..00afd02612a --- /dev/null +++ b/src/core/task-persistence/rooMessage.ts @@ -0,0 +1,161 @@ +/** + * 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, + UserContent, + AssistantContent, + ToolContent, +} 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[number] +export type ReasoningPart = Extract + +// ──────────────────────────────────────────────────────────────────────────── +// 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?: any[] +} + +/** + * 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) +} From 14cfae150e0dd78f65c288d20e3b3f05c8f71352 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 10 Feb 2026 16:06:13 -0500 Subject: [PATCH 2/3] fix: narrow RooReasoningMessage.summary type, remove unused converters/index.ts and unnecessary re-exports - Narrow summary from any[] to Array<{ type: string; text: string }> matching EncryptedReasoningItem - Delete unused converters/index.ts barrel (knip: unused file) - Remove UserContent, AssistantContent, ToolContent re-exports (importable from 'ai' directly) - Remove FlattenMessagesOptions re-export from index.ts --- src/core/task-persistence/converters/index.ts | 1 - src/core/task-persistence/index.ts | 1 - src/core/task-persistence/rooMessage.ts | 13 ++----------- 3 files changed, 2 insertions(+), 13 deletions(-) delete mode 100644 src/core/task-persistence/converters/index.ts diff --git a/src/core/task-persistence/converters/index.ts b/src/core/task-persistence/converters/index.ts deleted file mode 100644 index 3e963ace79c..00000000000 --- a/src/core/task-persistence/converters/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { convertAnthropicToRooMessages } from "./anthropicToRoo" diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index 6bc2fbc6ce6..fca89d965de 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -8,4 +8,3 @@ export { isRooUserMessage, isRooAssistantMessage, isRooToolMessage, isRooReasoni export type { TextPart, ImagePart, FilePart, ToolCallPart, ToolResultPart, ReasoningPart } from "./rooMessage" export { convertAnthropicToRooMessages } from "./converters/anthropicToRoo" export { flattenModelMessagesToStringContent } from "./messageUtils" -export type { FlattenMessagesOptions } from "./messageUtils" diff --git a/src/core/task-persistence/rooMessage.ts b/src/core/task-persistence/rooMessage.ts index 00afd02612a..a94cc32ebe6 100644 --- a/src/core/task-persistence/rooMessage.ts +++ b/src/core/task-persistence/rooMessage.ts @@ -11,16 +11,7 @@ import type { UserModelMessage, AssistantModelMessage, ToolModelMessage, AssistantContent } from "ai" // Re-export AI SDK content part types for convenience -export type { - TextPart, - ImagePart, - FilePart, - ToolCallPart, - ToolResultPart, - UserContent, - AssistantContent, - ToolContent, -} from "ai" +export type { TextPart, ImagePart, FilePart, ToolCallPart, ToolResultPart } from "ai" /** * `ReasoningPart` is used by the AI SDK in `AssistantContent` but is not directly @@ -102,7 +93,7 @@ export interface RooReasoningMessage extends RooMessageMetadata { /** Provider response ID. */ id?: string /** Summary of the reasoning, if provided by the model. */ - summary?: any[] + summary?: Array<{ type: string; text: string }> } /** From e8ea4920cfbbceb1a54828e22e05ed9f4caf8f23 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 10 Feb 2026 21:28:11 +0000 Subject: [PATCH 3/3] fix: mock safeWriteJson in saveRooMessages write failure test for cross-platform reliability --- .../task-persistence/__tests__/rooMessages.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/core/task-persistence/__tests__/rooMessages.spec.ts b/src/core/task-persistence/__tests__/rooMessages.spec.ts index 9c9c8b22810..55f3b7c9c74 100644 --- a/src/core/task-persistence/__tests__/rooMessages.spec.ts +++ b/src/core/task-persistence/__tests__/rooMessages.spec.ts @@ -8,6 +8,7 @@ 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 @@ -212,11 +213,17 @@ describe("saveRooMessages", () => { it("returns false on write failure", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - // Use a path that will fail (no permission or invalid) + // 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: "/nonexistent/readonly/path", + globalStoragePath: tmpBaseDir, }) expect(success).toBe(false)