Unverified 提交 fb615c33 authored 作者: wwwillchen-bot's avatar wwwillchen-bot 提交者: GitHub

Strip OpenAI itemId from persisted AI messages to prevent stale reference errors…

Strip OpenAI itemId from persisted AI messages to prevent stale reference errors but keep encrypted reasoning content (#2468) ## Summary - Strip `providerOptions.openai.itemId` (and `azure.itemId`) from persisted AI messages when parsing from DB - Prevents "Item with id not found" errors when OpenAI expires server-side stored items and the AI SDK sends `item_reference` payloads instead of full content - Adds comprehensive unit tests covering text parts, tool-call parts, reasoning parts (preserving `reasoningEncryptedContent`), legacy formats, and mixed provider options ## Test plan - [x] Unit tests added in `ai_messages_utils.test.ts` covering all stripping scenarios - [ ] Verify existing conversations with OpenAI models continue to work correctly - [ ] Verify that long conversations no longer produce "Item with id not found" errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2468"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Prevents "Item with id not found" errors by stripping openai/azure itemId from persisted message parts during parse, so the SDK always sends full content. Keeps long conversations stable without losing content. - **Bug Fixes** - Remove itemId under providerOptions/providerMetadata for openai and azure across text, tool-call, and reasoning parts, including legacy formats. - Preserve reasoningEncryptedContent and request encrypted reasoning via provider options; leave non-openai provider data and string-only messages unchanged. - Add unit tests covering stripping, legacy formats, mixed providers, and no-op cases. <sup>Written for commit a4c02d52ea9f25139d4b77bf1a2bdb9f663065f7. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarWill Chen <willchen90@gmail.com> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 933dda65
......@@ -204,6 +204,245 @@ describe("parseAiMessagesJson", () => {
});
});
describe("OpenAI itemId stripping", () => {
it("should strip itemId from text parts with providerOptions", () => {
const msg: DbMessageForParsing = {
id: 20,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: "Hello",
providerOptions: {
openai: { itemId: "msg_abc123" },
},
},
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
const part = (result[0].content as any[])[0];
expect(part.text).toBe("Hello");
expect(part.providerOptions).toBeUndefined();
});
it("should strip itemId from tool-call parts", () => {
const msg: DbMessageForParsing = {
id: 21,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-123",
toolName: "read_file",
input: { path: "/test" },
providerOptions: {
openai: { itemId: "fc_abc123" },
},
},
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
const part = (result[0].content as any[])[0];
expect(part.toolCallId).toBe("call-123");
expect(part.providerOptions).toBeUndefined();
});
it("should strip itemId from reasoning parts but preserve reasoningEncryptedContent", () => {
const msg: DbMessageForParsing = {
id: 22,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "reasoning",
text: "thinking...",
providerOptions: {
openai: {
itemId: "rs_abc123",
reasoningEncryptedContent: "encrypted-data",
},
},
},
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
const part = (result[0].content as any[])[0];
expect(part.text).toBe("thinking...");
expect(part.providerOptions.openai.itemId).toBeUndefined();
expect(part.providerOptions.openai.reasoningEncryptedContent).toBe(
"encrypted-data",
);
});
it("should strip itemId from legacy providerMetadata", () => {
const msg: DbMessageForParsing = {
id: 23,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: "Hello",
providerMetadata: {
openai: { itemId: "msg_legacy123" },
},
} as any,
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
const part = (result[0].content as any[])[0];
expect(part.text).toBe("Hello");
expect(part.providerMetadata).toBeUndefined();
});
it("should strip itemId from legacy array format", () => {
const msg: DbMessageForParsing = {
id: 24,
role: "assistant",
content: "fallback",
aiMessagesJson: [
{
role: "assistant",
content: [
{
type: "text",
text: "Legacy",
providerOptions: {
openai: { itemId: "msg_legacy_arr" },
},
},
],
},
] as ModelMessage[],
};
const result = parseAiMessagesJson(msg);
const part = (result[0].content as any[])[0];
expect(part.text).toBe("Legacy");
expect(part.providerOptions).toBeUndefined();
});
it("should strip itemId from azure provider key", () => {
const msg: DbMessageForParsing = {
id: 25,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: "Azure",
providerOptions: {
azure: { itemId: "msg_azure123" },
},
},
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
const part = (result[0].content as any[])[0];
expect(part.text).toBe("Azure");
expect(part.providerOptions).toBeUndefined();
});
it("should preserve non-OpenAI providerOptions", () => {
const msg: DbMessageForParsing = {
id: 26,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: "Mixed",
providerOptions: {
openai: { itemId: "msg_strip" },
"dyad-engine": { someFlag: true },
},
},
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
const part = (result[0].content as any[])[0];
expect(part.providerOptions.openai).toBeUndefined();
expect(part.providerOptions["dyad-engine"]).toEqual({ someFlag: true });
});
it("should not modify string content messages", () => {
const msg: DbMessageForParsing = {
id: 27,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
],
},
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
]);
});
});
describe("edge cases", () => {
it("should handle empty content in fallback", () => {
const msg: DbMessageForParsing = {
......
......@@ -4,6 +4,66 @@ import log from "electron-log";
const logger = log.scope("ai_messages_utils");
/**
* Provider option keys that may contain itemId references to OpenAI's
* server-side storage. These references become stale when items expire.
*/
const PROVIDER_KEYS_WITH_ITEM_ID = ["openai", "azure"] as const;
/**
* Strip OpenAI item IDs from provider metadata on all message content parts.
*
* When messages are persisted to DB with aiMessagesJson, they may contain
* `providerMetadata.openai.itemId` values that reference items stored on OpenAI's
* servers. On subsequent turns, the AI SDK converts these to `item_reference`
* payloads instead of sending full content. If OpenAI has expired those items,
* this causes "Item with id 'rs_...' not found" errors.
*
* Stripping itemId forces the SDK to always send full message content, which is
* already stored in the message parts alongside the itemId, so no data is lost.
*/
function stripItemIds(messages: ModelMessage[]): ModelMessage[] {
for (const message of messages) {
if (typeof message.content === "string") continue;
if (!Array.isArray(message.content)) continue;
for (const part of message.content) {
stripItemIdFromObject(part as Record<string, unknown>);
}
}
return messages;
}
function stripItemIdFromObject(obj: Record<string, unknown>): void {
for (const field of ["providerOptions", "providerMetadata"] as const) {
const container = obj[field];
if (!container || typeof container !== "object") continue;
const containerRecord = container as Record<
string,
Record<string, unknown>
>;
for (const key of PROVIDER_KEYS_WITH_ITEM_ID) {
const providerData = containerRecord[key];
if (
providerData &&
typeof providerData === "object" &&
"itemId" in providerData
) {
delete providerData.itemId;
// Clean up empty provider data
if (Object.keys(providerData).length === 0) {
delete containerRecord[key];
}
}
}
// Clean up empty container
if (Object.keys(containerRecord).length === 0) {
delete obj[field];
}
}
}
/** Maximum size in bytes for ai_messages_json (10MB) */
export const MAX_AI_MESSAGES_SIZE = 10_000_000;
......@@ -46,8 +106,6 @@ export type DbMessageForParsing = {
* Parse ai_messages_json with graceful fallback to simple content reconstruction.
* If aiMessagesJson is missing, malformed, or incompatible with the current AI SDK,
* falls back to constructing a basic message from role and content.
*
* This is a pure function - it doesn't log or have side effects.
*/
export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
if (msg.aiMessagesJson) {
......@@ -58,7 +116,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
Array.isArray(parsed) &&
parsed.every((m) => m && typeof m.role === "string")
) {
return parsed;
return stripItemIds(parsed);
}
if (
......@@ -72,7 +130,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
(m: ModelMessage) => m && typeof m.role === "string",
)
) {
return (parsed as AiMessagesJsonV6).messages;
return stripItemIds((parsed as AiMessagesJsonV6).messages);
}
}
......
......@@ -30,6 +30,7 @@ export function getExtraProviderOptions(
summary: "detailed",
effort: "medium",
},
include: ["reasoning.encrypted_content"],
};
}
return { reasoning_effort: "medium" };
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论