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

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Daniel 2026-02-10 16:41:35 -05:00 committed by GitHub
parent 4438fdadc6
commit 50e5d98f5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2358 additions and 0 deletions

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

@ -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")
})
})

View file

@ -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
}
}

File diff suppressed because it is too large Load diff

View file

@ -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
}

View file

@ -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"

View file

@ -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
})
}

View file

@ -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)
}