mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: RooMessage type system and storage layer for ModelMessage migration (#11380)
Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
parent
4438fdadc6
commit
50e5d98f5b
9 changed files with 2358 additions and 0 deletions
115
src/core/task-persistence/__tests__/messageUtils.spec.ts
Normal file
115
src/core/task-persistence/__tests__/messageUtils.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
229
src/core/task-persistence/__tests__/rooMessage.spec.ts
Normal file
229
src/core/task-persistence/__tests__/rooMessage.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
277
src/core/task-persistence/__tests__/rooMessages.spec.ts
Normal file
277
src/core/task-persistence/__tests__/rooMessages.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
308
src/core/task-persistence/converters/anthropicToRoo.ts
Normal file
308
src/core/task-persistence/converters/anthropicToRoo.ts
Normal 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
|
||||
}
|
||||
|
|
@ -1,3 +1,10 @@
|
|||
export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages"
|
||||
export { detectFormat, readRooMessages, saveRooMessages } from "./apiMessages"
|
||||
export { readTaskMessages, saveTaskMessages } from "./taskMessages"
|
||||
export { taskMetadata } from "./taskMetadata"
|
||||
export type { RooMessage, RooMessageHistory, RooMessageMetadata } from "./rooMessage"
|
||||
export type { RooUserMessage, RooAssistantMessage, RooToolMessage, RooReasoningMessage } from "./rooMessage"
|
||||
export { isRooUserMessage, isRooAssistantMessage, isRooToolMessage, isRooReasoningMessage } from "./rooMessage"
|
||||
export type { TextPart, ImagePart, FilePart, ToolCallPart, ToolResultPart, ReasoningPart } from "./rooMessage"
|
||||
export { convertAnthropicToRooMessages } from "./converters/anthropicToRoo"
|
||||
export { flattenModelMessagesToStringContent } from "./messageUtils"
|
||||
|
|
|
|||
69
src/core/task-persistence/messageUtils.ts
Normal file
69
src/core/task-persistence/messageUtils.ts
Normal 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
|
||||
})
|
||||
}
|
||||
152
src/core/task-persistence/rooMessage.ts
Normal file
152
src/core/task-persistence/rooMessage.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue