refactor: rename ApiMessage to LegacyApiMessage and strip cache providerOptions on save

This commit is contained in:
Hannes Rudolph 2026-02-12 19:45:22 -07:00
parent 26899bf601
commit b8c8749106
11 changed files with 293 additions and 49 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"
import { ApiMessage } from "../../task-persistence/apiMessages"
import { LegacyApiMessage } from "../../task-persistence/apiMessages"
import { getEffectiveApiHistory, getMessagesSinceLastSummary } from "../index"
describe("nested condensing scenarios", () => {

View file

@ -12,7 +12,7 @@
import { TelemetryService } from "@roo-code/telemetry"
import { getEffectiveApiHistory, cleanupAfterTruncation } from "../index"
import { ApiMessage } from "../../task-persistence/apiMessages"
import { LegacyApiMessage } from "../../task-persistence/apiMessages"
describe("Rewind After Condense - Issue #8295", () => {
beforeEach(() => {

View file

@ -1,7 +1,7 @@
import * as path from "path"
import { Task } from "../task/Task"
import { ClineMessage } from "@roo-code/types"
import { ApiMessage } from "../task-persistence/apiMessages"
import { LegacyApiMessage } from "../task-persistence/apiMessages"
import { cleanupAfterTruncation } from "../condense"
import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor"
import { getTaskDirectoryPath } from "../../utils/storage"

View file

@ -4,8 +4,8 @@ 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 { detectFormat, readRooMessages, saveRooMessages, stripCacheProviderOptions } from "../apiMessages"
import type { LegacyApiMessage } from "../apiMessages"
import type { RooMessage, RooMessageHistory } from "../rooMessage"
import { ROO_MESSAGE_VERSION } from "../rooMessage"
import * as safeWriteJsonModule from "../../../utils/safeWriteJson"
@ -46,7 +46,7 @@ const sampleV2Envelope: RooMessageHistory = {
messages: sampleRooMessages,
}
const sampleLegacyMessages: ApiMessage[] = [
const sampleLegacyMessages: LegacyApiMessage[] = [
{ role: "user", content: "Hello from legacy", ts: 1000 },
{ role: "assistant", content: "Legacy response", ts: 2000 },
]
@ -275,3 +275,198 @@ describe("round-trip", () => {
expect(detectFormat(parsed)).toBe("v2")
})
})
// ────────────────────────────────────────────────────────────────────────────
// stripCacheProviderOptions
// ────────────────────────────────────────────────────────────────────────────
describe("stripCacheProviderOptions", () => {
it("returns messages unchanged when they have no providerOptions", () => {
const messages: RooMessage[] = [
{ role: "user" as const, content: [{ type: "text" as const, text: "hi" }] },
{ role: "assistant" as const, content: [{ type: "text" as const, text: "hello" }] },
]
const result = stripCacheProviderOptions(messages)
expect(result).toEqual(messages)
})
it("strips anthropic.cacheControl and removes empty providerOptions", () => {
const messages: RooMessage[] = [
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
providerOptions: {
anthropic: { cacheControl: { type: "ephemeral" } },
},
},
]
const result = stripCacheProviderOptions(messages)
expect(result).toEqual([
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
},
])
expect(result[0]).not.toHaveProperty("providerOptions")
})
it("strips bedrock.cachePoint and removes empty providerOptions", () => {
const messages: RooMessage[] = [
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
},
]
const result = stripCacheProviderOptions(messages)
expect(result).toEqual([
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
},
])
expect(result[0]).not.toHaveProperty("providerOptions")
})
it("strips both anthropic.cacheControl and bedrock.cachePoint simultaneously", () => {
const messages: RooMessage[] = [
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
providerOptions: {
anthropic: { cacheControl: { type: "ephemeral" } },
bedrock: { cachePoint: { type: "default" } },
},
},
]
const result = stripCacheProviderOptions(messages)
expect(result).toEqual([
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
},
])
expect(result[0]).not.toHaveProperty("providerOptions")
})
it("preserves anthropic.signature while stripping anthropic.cacheControl", () => {
const messages: RooMessage[] = [
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
providerOptions: {
anthropic: { cacheControl: { type: "ephemeral" }, signature: "abc123" },
},
},
]
const result = stripCacheProviderOptions(messages)
expect(result).toEqual([
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
providerOptions: {
anthropic: { signature: "abc123" },
},
},
])
})
it("preserves openrouter.reasoning_details unchanged", () => {
const reasoningDetails = [{ type: "thinking", thinking: "hmm" }]
const messages: RooMessage[] = [
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
providerOptions: {
openrouter: { reasoning_details: reasoningDetails },
},
},
]
const result = stripCacheProviderOptions(messages)
expect(result).toEqual([
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
providerOptions: {
openrouter: { reasoning_details: reasoningDetails },
},
},
])
})
it("strips cache keys while preserving non-cache keys across namespaces", () => {
const reasoningDetails = [{ type: "thinking", thinking: "hmm" }]
const messages: RooMessage[] = [
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
providerOptions: {
anthropic: { cacheControl: { type: "ephemeral" }, signature: "abc123" },
bedrock: { cachePoint: { type: "default" } },
openrouter: { reasoning_details: reasoningDetails },
},
},
]
const result = stripCacheProviderOptions(messages)
expect(result).toEqual([
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
providerOptions: {
anthropic: { signature: "abc123" },
openrouter: { reasoning_details: reasoningDetails },
},
},
])
// bedrock namespace should be fully removed
const resultOptions = (result[0] as unknown as Record<string, unknown>).providerOptions as Record<string, unknown>
expect(resultOptions).not.toHaveProperty("bedrock")
})
it("does not mutate the original array", () => {
const original: RooMessage[] = [
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hello" }],
providerOptions: {
anthropic: { cacheControl: { type: "ephemeral" }, signature: "abc123" },
},
},
]
// Snapshot original state before calling
const originalSnapshot = JSON.parse(JSON.stringify(original))
stripCacheProviderOptions(original)
expect(original).toEqual(originalSnapshot)
// Verify the providerOptions still has cacheControl on the original
const originalOptions = (original[0] as unknown as Record<string, unknown>).providerOptions as Record<
string,
Record<string, unknown>
>
expect(originalOptions["anthropic"]["cacheControl"]).toEqual({ type: "ephemeral" })
})
it("returns empty array for empty input", () => {
const result = stripCacheProviderOptions([])
expect(result).toEqual([])
})
})

View file

@ -12,7 +12,10 @@ import type { RooMessage, RooMessageHistory } from "./rooMessage"
import { ROO_MESSAGE_VERSION } from "./rooMessage"
import { convertAnthropicToRooMessages } from "./converters/anthropicToRoo"
export type ApiMessage = Anthropic.MessageParam & {
/**
* @deprecated This is the legacy Anthropic message format. Use {@link RooMessage} for the current format.
*/
export type LegacyApiMessage = Anthropic.MessageParam & {
ts?: number
isSummary?: boolean
id?: string
@ -40,13 +43,16 @@ export type ApiMessage = Anthropic.MessageParam & {
isTruncationMarker?: boolean
}
/** @deprecated Use {@link LegacyApiMessage} directly. This alias exists for backward compatibility only. */
export type ApiMessage = LegacyApiMessage
export async function readApiMessages({
taskId,
globalStoragePath,
}: {
taskId: string
globalStoragePath: string
}): Promise<ApiMessage[]> {
}): Promise<LegacyApiMessage[]> {
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
@ -114,7 +120,7 @@ export async function saveApiMessages({
taskId,
globalStoragePath,
}: {
messages: ApiMessage[]
messages: LegacyApiMessage[]
taskId: string
globalStoragePath: string
}) {
@ -194,7 +200,7 @@ export async function readRooMessages({
return []
}
return convertAnthropicToRooMessages(parsedData as ApiMessage[])
return convertAnthropicToRooMessages(parsedData as LegacyApiMessage[])
}
const primaryResult = await tryParseFile(filePath)
@ -214,6 +220,48 @@ export async function readRooMessages({
return []
}
/**
* Strip transient cache-control provider options that are applied at request
* time by applyCacheBreakpoints() and should not be persisted.
*
* Removes:
* - anthropic.cacheControl
* - bedrock.cachePoint
*
* Preserves all other providerOptions (e.g. anthropic.signature, openrouter.reasoning_details).
*/
export function stripCacheProviderOptions(messages: RooMessage[]): RooMessage[] {
const cloned = structuredClone(messages)
for (const msg of cloned) {
if (!("providerOptions" in msg) || (msg as { providerOptions?: unknown }).providerOptions == null) {
continue
}
const providerOptions = (msg as { providerOptions: Record<string, Record<string, unknown>> }).providerOptions
if (providerOptions["anthropic"] != null) {
delete providerOptions["anthropic"]["cacheControl"]
if (Object.keys(providerOptions["anthropic"]).length === 0) {
delete providerOptions["anthropic"]
}
}
if (providerOptions["bedrock"] != null) {
delete providerOptions["bedrock"]["cachePoint"]
if (Object.keys(providerOptions["bedrock"]).length === 0) {
delete providerOptions["bedrock"]
}
}
if (Object.keys(providerOptions).length === 0) {
delete (msg as { providerOptions?: unknown }).providerOptions
}
}
return cloned
}
/**
* Save `RooMessage[]` wrapped in the versioned `RooMessageHistory` envelope.
*
@ -234,9 +282,10 @@ export async function saveRooMessages({
try {
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
const strippedMessages = stripCacheProviderOptions(messages)
const envelope: RooMessageHistory = {
version: ROO_MESSAGE_VERSION,
messages,
messages: strippedMessages,
}
await safeWriteJson(filePath, envelope)
return true

View file

@ -1,4 +1,4 @@
import type { ApiMessage } from "../../apiMessages"
import type { LegacyApiMessage } from "../../apiMessages"
import type {
RooUserMessage,
RooAssistantMessage,
@ -16,9 +16,9 @@ import { convertAnthropicToRooMessages } from "../anthropicToRoo"
// Helpers
// ────────────────────────────────────────────────────────────────────────────
/** Shorthand to create an ApiMessage with required fields. */
function apiMsg(overrides: Partial<ApiMessage> & Pick<ApiMessage, "role" | "content">): ApiMessage {
return overrides as ApiMessage
/** Shorthand to create a LegacyApiMessage with required fields. */
function apiMsg(overrides: Partial<LegacyApiMessage> & Pick<LegacyApiMessage, "role" | "content">): LegacyApiMessage {
return overrides as LegacyApiMessage
}
// ────────────────────────────────────────────────────────────────────────────
@ -139,7 +139,7 @@ describe("user messages with URL image content", () => {
describe("user messages with tool_result blocks", () => {
test("splits tool_result into RooToolMessage before RooUserMessage", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
apiMsg({
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "read_file", input: { path: "foo.ts" } }],
@ -179,7 +179,7 @@ describe("user messages with tool_result blocks", () => {
})
test("handles tool_result with array content (joins text with newlines)", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
apiMsg({
role: "assistant",
content: [{ type: "tool_use", id: "call_2", name: "list_files", input: {} }],
@ -204,7 +204,7 @@ describe("user messages with tool_result blocks", () => {
})
test("handles tool_result with undefined content → (empty)", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
apiMsg({
role: "assistant",
content: [{ type: "tool_use", id: "call_3", name: "run_command", input: {} }],
@ -220,7 +220,7 @@ describe("user messages with tool_result blocks", () => {
})
test("handles tool_result with empty string content → (empty)", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
apiMsg({
role: "assistant",
content: [{ type: "tool_use", id: "call_4", name: "run_command", input: {} }],
@ -242,7 +242,7 @@ describe("user messages with tool_result blocks", () => {
describe("user messages with mixed tool_result and text", () => {
test("separates tool results from text/image parts correctly", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
apiMsg({
role: "assistant",
content: [
@ -287,7 +287,7 @@ describe("user messages with mixed tool_result and text", () => {
})
test("only emits tool message when no text/image parts exist", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
apiMsg({
role: "assistant",
content: [{ type: "tool_use", id: "tc_only", name: "some_tool", input: {} }],
@ -733,7 +733,7 @@ describe("metadata preservation", () => {
})
test("carries over metadata on tool messages (split from user)", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
apiMsg({
role: "assistant",
content: [{ type: "tool_use", id: "tc_meta", name: "my_tool", input: {} }],
@ -787,7 +787,7 @@ describe("metadata preservation", () => {
describe("tool name resolution", () => {
test("resolves tool names from preceding assistant messages", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
apiMsg({
role: "assistant",
content: [{ type: "tool_use", id: "tc_x", name: "execute_command", input: { command: "ls" } }],
@ -803,7 +803,7 @@ describe("tool name resolution", () => {
})
test("falls back to unknown_tool when tool call ID is not found", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
apiMsg({
role: "user",
content: [{ type: "tool_result", tool_use_id: "nonexistent_id", content: "result" }],
@ -815,7 +815,7 @@ describe("tool name resolution", () => {
})
test("resolves tool names across multiple assistant messages", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
apiMsg({
role: "assistant",
content: [{ type: "tool_use", id: "tc_first", name: "tool_alpha", input: {} }],
@ -878,7 +878,7 @@ describe("empty/undefined content edge cases", () => {
})
test("handles tool_result with image content blocks", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
apiMsg({
role: "assistant",
content: [{ type: "tool_use", id: "tc_img", name: "screenshot", input: {} }],
@ -914,7 +914,7 @@ describe("empty/undefined content edge cases", () => {
describe("full conversation round-trip", () => {
test("converts a realistic multi-turn conversation", () => {
const messages: ApiMessage[] = [
const messages: LegacyApiMessage[] = [
// Turn 1: user asks a question
apiMsg({ role: "user", content: "Can you read my config file?", ts: 1000 }),
// Turn 2: assistant uses a tool

View file

@ -1,5 +1,5 @@
/**
* Converter from Anthropic-format `ApiMessage` to the new `RooMessage` format.
* Converter from Anthropic-format `LegacyApiMessage` 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.
@ -9,7 +9,7 @@
*/
import type { TextPart, ImagePart, ToolCallPart, ToolResultPart, ReasoningPart } from "../rooMessage"
import type { ApiMessage } from "../apiMessages"
import type { LegacyApiMessage } from "../apiMessages"
import type {
RooMessage,
RooUserMessage,
@ -28,10 +28,10 @@ import type {
type LooseProviderOptions = Record<string, Record<string, unknown>>
/**
* Extract Roo-specific metadata fields from an ApiMessage.
* Extract Roo-specific metadata fields from a LegacyApiMessage.
* Only includes fields that are actually defined (avoids `undefined` keys).
*/
function extractMetadata(message: ApiMessage): RooMessageMetadata {
function extractMetadata(message: LegacyApiMessage): RooMessageMetadata {
const metadata: RooMessageMetadata = {}
if (message.ts !== undefined) metadata.ts = message.ts
if (message.condenseId !== undefined) metadata.condenseId = message.condenseId
@ -82,7 +82,7 @@ function attachReasoningDetails(
}
/**
* Convert an array of Anthropic-format `ApiMessage` objects to `RooMessage` format.
* Convert an array of Anthropic-format `LegacyApiMessage` objects to `RooMessage` format.
*
* Conversion rules:
* - User string content `RooUserMessage` with `content: string`
@ -93,10 +93,10 @@ function attachReasoningDetails(
* - Standalone reasoning messages `RooReasoningMessage`
* - Metadata fields (ts, condenseId, etc.) are preserved on all output messages
*
* @param messages - Array of ApiMessage (Anthropic format with metadata)
* @param messages - Array of LegacyApiMessage (Anthropic format with metadata)
* @returns Array of RooMessage objects
*/
export function convertAnthropicToRooMessages(messages: ApiMessage[]): RooMessage[] {
export function convertAnthropicToRooMessages(messages: LegacyApiMessage[]): RooMessage[] {
const result: RooMessage[] = []
// First pass: build a map of tool call IDs to tool names from assistant messages.

View file

@ -1,5 +1,5 @@
export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages"
export { detectFormat, readRooMessages, saveRooMessages } from "./apiMessages"
export { type LegacyApiMessage, type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages"
export { detectFormat, readRooMessages, saveRooMessages, stripCacheProviderOptions } from "./apiMessages"
export { readTaskMessages, saveTaskMessages } from "./taskMessages"
export { taskMetadata } from "./taskMetadata"
export type { RooMessage, RooMessageHistory, RooMessageMetadata } from "./rooMessage"

View file

@ -110,7 +110,7 @@ import { manageContext, willManageContext } from "../context-management"
import { ClineProvider } from "../webview/ClineProvider"
import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace"
import {
type ApiMessage,
type LegacyApiMessage,
readApiMessages,
saveApiMessages,
readTaskMessages,

View file

@ -40,7 +40,7 @@ vi.mock("../checkpointRestoreHandler", () => ({
import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
import type { ClineMessage } from "@roo-code/types"
import type { ApiMessage } from "../../task-persistence/apiMessages"
import type { LegacyApiMessage } from "../../task-persistence/apiMessages"
import { MessageManager } from "../../message-manager"
describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => {
@ -54,7 +54,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => {
mockCurrentTask = {
taskId: "test-task-id",
clineMessages: [] as ClineMessage[],
apiConversationHistory: [] as ApiMessage[],
apiConversationHistory: [] as LegacyApiMessage[],
overwriteClineMessages: vi.fn(),
overwriteApiConversationHistory: vi.fn(),
handleWebviewAskResponse: vi.fn(),
@ -126,7 +126,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => {
},
],
},
] as ApiMessage[]
] as LegacyApiMessage[]
// Trigger edit confirmation
await webviewMessageHandler(mockClineProvider, {
@ -184,7 +184,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => {
role: "assistant",
content: [{ type: "text", text: "Response" }],
},
] as ApiMessage[]
] as LegacyApiMessage[]
await webviewMessageHandler(mockClineProvider, {
type: "editMessageConfirm",
@ -244,7 +244,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => {
role: "assistant",
content: [{ type: "text", text: "Response" }],
},
] as ApiMessage[]
] as LegacyApiMessage[]
await webviewMessageHandler(mockClineProvider, {
type: "editMessageConfirm",
@ -282,7 +282,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => {
role: "assistant",
content: [{ type: "text", text: "Old message 2" }],
},
] as ApiMessage[]
] as LegacyApiMessage[]
await webviewMessageHandler(mockClineProvider, {
type: "editMessageConfirm",
@ -378,7 +378,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => {
},
],
},
] as ApiMessage[]
] as LegacyApiMessage[]
// Edit the first user message
await webviewMessageHandler(mockClineProvider, {

View file

@ -25,7 +25,7 @@ import { customToolRegistry } from "@roo-code/core"
import { CloudService } from "@roo-code/cloud"
import { TelemetryService } from "@roo-code/telemetry"
import { type ApiMessage } from "../task-persistence/apiMessages"
import { type LegacyApiMessage } from "../task-persistence/apiMessages"
import { saveTaskMessages } from "../task-persistence"
import { ClineProvider } from "./ClineProvider"
@ -131,11 +131,11 @@ export const webviewMessageHandler = async (
// Find all matching API messages by timestamp
const allApiMatches = currentCline.apiConversationHistory
.map((msg: ApiMessage, idx: number) => ({ msg, idx }))
.filter(({ msg }: { msg: ApiMessage }) => msg.ts === messageTs)
.map((msg: LegacyApiMessage, idx: number) => ({ msg, idx }))
.filter(({ msg }: { msg: LegacyApiMessage }) => msg.ts === messageTs)
// Prefer non-summary message if multiple matches exist (handles timestamp collision after condense)
const preferred = allApiMatches.find(({ msg }: { msg: ApiMessage }) => !msg.isSummary) || allApiMatches[0]
const preferred = allApiMatches.find(({ msg }: { msg: LegacyApiMessage }) => !msg.isSummary) || allApiMatches[0]
const apiConversationHistoryIndex = preferred?.idx ?? -1
return { messageIndex, apiConversationHistoryIndex }
@ -148,7 +148,7 @@ export const webviewMessageHandler = async (
const findFirstApiIndexAtOrAfter = (ts: number, currentCline: any) => {
if (typeof ts !== "number") return -1
return currentCline.apiConversationHistory.findIndex(
(msg: ApiMessage) => typeof msg?.ts === "number" && (msg.ts as number) >= ts,
(msg: LegacyApiMessage) => typeof msg?.ts === "number" && (msg.ts as number) >= ts,
)
}