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", () => { ...@@ -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", () => { describe("edge cases", () => {
it("should handle empty content in fallback", () => { it("should handle empty content in fallback", () => {
const msg: DbMessageForParsing = { const msg: DbMessageForParsing = {
......
...@@ -4,6 +4,66 @@ import log from "electron-log"; ...@@ -4,6 +4,66 @@ import log from "electron-log";
const logger = log.scope("ai_messages_utils"); 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) */ /** Maximum size in bytes for ai_messages_json (10MB) */
export const MAX_AI_MESSAGES_SIZE = 10_000_000; export const MAX_AI_MESSAGES_SIZE = 10_000_000;
...@@ -46,8 +106,6 @@ export type DbMessageForParsing = { ...@@ -46,8 +106,6 @@ export type DbMessageForParsing = {
* Parse ai_messages_json with graceful fallback to simple content reconstruction. * Parse ai_messages_json with graceful fallback to simple content reconstruction.
* If aiMessagesJson is missing, malformed, or incompatible with the current AI SDK, * If aiMessagesJson is missing, malformed, or incompatible with the current AI SDK,
* falls back to constructing a basic message from role and content. * 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[] { export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
if (msg.aiMessagesJson) { if (msg.aiMessagesJson) {
...@@ -58,7 +116,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] { ...@@ -58,7 +116,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
Array.isArray(parsed) && Array.isArray(parsed) &&
parsed.every((m) => m && typeof m.role === "string") parsed.every((m) => m && typeof m.role === "string")
) { ) {
return parsed; return stripItemIds(parsed);
} }
if ( if (
...@@ -72,7 +130,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] { ...@@ -72,7 +130,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
(m: ModelMessage) => m && typeof m.role === "string", (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( ...@@ -30,6 +30,7 @@ export function getExtraProviderOptions(
summary: "detailed", summary: "detailed",
effort: "medium", effort: "medium",
}, },
include: ["reasoning.encrypted_content"],
}; };
} }
return { reasoning_effort: "medium" }; return { reasoning_effort: "medium" };
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论