Unverified 提交 aa71f805 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

fix: filter orphaned reasoning items to prevent OpenAI API error (#2508)

fixes https://github.com/dyad-sh/dyad/issues/2500 ## Summary - Adds `filterOrphanedReasoningParts()` function to filter out OpenAI reasoning items that aren't followed by output items - Fixes error: "Item of type 'reasoning' was provided without its required following item" - This error occurs with OpenAI reasoning models (o1, o3, o4-mini) when conversation history contains orphaned reasoning items ## Test plan - Added 4 unit tests covering: - Reasoning with following output is preserved - Orphaned reasoning (alone in message) is filtered out - Reasoning followed by tool-call is preserved - Trailing reasoning after text output is filtered - All 672 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2508" target="_blank"> <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 --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches message-history shaping sent to OpenAI (including multi-step local-agent flows); incorrect filtering could drop reasoning parts or change message arrays, potentially impacting model behavior, but changes are scoped and covered by new unit tests. > > **Overview** > Prevents OpenAI Responses API errors by **cleaning conversation history** before reuse: message parts now have stale `providerOptions/providerMetadata.{openai,azure}.itemId` removed and **orphaned `reasoning` parts (not followed by any non-reasoning output)** filtered out via `cleanMessageForOpenAI()`. > > This cleaning is applied both when parsing persisted `aiMessagesJson` (`parseAiMessagesJson`) and during local-agent multi-step `prepareStepMessages`, with expanded unit tests covering preservation vs filtering cases; `thinking_utils` also disables OpenAI `store` for `local-agent` mode and `AGENTS.md` documents the failure mode and fix. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5b0c70c2817345598e2eb88dcefbb0f859d6bb88. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Filters orphaned reasoning parts in conversation history to prevent OpenAI Responses API errors when a reasoning item isn’t followed by output. Applies filtering when parsing stored messages and during multi-step prepare step. - **Bug Fixes** - Replaced stripItemIds with cleanMessageForOpenAI in parseAiMessagesJson and prepareStepMessages to strip provider itemId and filter orphaned reasoning parts. - Preserves reasoning when followed by text or tool-call, preventing “reasoning without required following item” errors on o1/o3/o4-mini; sets store: false to avoid expired item references. - Expanded tests for parsing and multi-step flows; documented behavior and fix in AGENTS.md. <sup>Written for commit 5b0c70c2817345598e2eb88dcefbb0f859d6bb88. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 2661ba3b
......@@ -282,3 +282,19 @@ When rebasing a branch that has drizzle migrations conflicting with upstream (e.
3. Update `drizzle/meta/_journal.json` to include all migrations with correct indices
4. Create/update the snapshot file (`drizzle/meta/00XX_snapshot.json`) with the new index, updating `prevId` to reference the previous snapshot's `id`
5. If the PR had subsequent commits that deleted/modified its migration files, those changes become no-ops after renaming — just accept the deletion conflicts by staging the renamed files
### OpenAI reasoning model errors with conversation history
When using OpenAI reasoning models (o1, o3, o4-mini) via LiteLLM/Azure, you may see:
```
Item 'rs_...' of type 'reasoning' was provided without its required following item.
```
OpenAI's Responses API requires reasoning items to always be followed by an output item (text, tool-call). This error occurs when:
- The model produces reasoning then immediately makes tool calls (no text between)
- The stream is interrupted after reasoning but before output
- Only reasoning was generated in a turn
The fix in `src/ipc/utils/ai_messages_utils.ts` filters orphaned reasoning parts via `filterOrphanedReasoningParts()` before sending conversation history back to OpenAI.
......@@ -267,7 +267,7 @@ describe("parseAiMessagesJson", () => {
expect(part.providerOptions).toBeUndefined();
});
it("should strip itemId from reasoning parts but preserve reasoningEncryptedContent", () => {
it("should strip itemId from reasoning parts but preserve reasoningEncryptedContent when followed by output", () => {
const msg: DbMessageForParsing = {
id: 22,
role: "assistant",
......@@ -288,6 +288,10 @@ describe("parseAiMessagesJson", () => {
},
},
},
{
type: "text",
text: "Here is my response",
},
],
},
] as ModelMessage[],
......@@ -295,12 +299,109 @@ describe("parseAiMessagesJson", () => {
};
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",
);
expect((result[0].content as any[]).length).toBe(2);
const reasoningPart = (result[0].content as any[])[0];
expect(reasoningPart.text).toBe("thinking...");
expect(reasoningPart.providerOptions.openai.itemId).toBeUndefined();
expect(
reasoningPart.providerOptions.openai.reasoningEncryptedContent,
).toBe("encrypted-data");
});
it("should filter out orphaned reasoning parts without following output", () => {
const msg: DbMessageForParsing = {
id: 22,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "reasoning",
text: "thinking without output...",
providerOptions: {
openai: {
itemId: "rs_orphan",
reasoningEncryptedContent: "encrypted-data",
},
},
},
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
// Orphaned reasoning should be filtered out
expect((result[0].content as any[]).length).toBe(0);
});
it("should keep reasoning followed by tool-call", () => {
const msg: DbMessageForParsing = {
id: 22,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "reasoning",
text: "thinking before tool call...",
},
{
type: "tool-call",
toolCallId: "call-123",
toolName: "read_file",
input: { path: "/test" },
},
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
expect((result[0].content as any[]).length).toBe(2);
expect((result[0].content as any[])[0].type).toBe("reasoning");
expect((result[0].content as any[])[1].type).toBe("tool-call");
});
it("should filter trailing reasoning after text output", () => {
const msg: DbMessageForParsing = {
id: 22,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: "output first",
},
{
type: "reasoning",
text: "orphaned reasoning at end",
},
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
// Trailing reasoning without following output should be filtered
expect((result[0].content as any[]).length).toBe(1);
expect((result[0].content as any[])[0].type).toBe("text");
});
it("should strip itemId from legacy providerMetadata", () => {
......
......@@ -612,4 +612,190 @@ describe("prepare_step_utils", () => {
`);
});
});
describe("orphaned reasoning filtering", () => {
it("filters orphaned reasoning parts during multi-step flow", () => {
const pendingUserMessages: UserMessageContentPart[][] = [];
const allInjectedMessages: InjectedMessage[] = [];
// Simulate AI SDK accumulating messages with orphaned reasoning
const messages: ModelMessage[] = [
{ role: "user", content: "Help me with this task" },
{
role: "assistant",
content: [
{ type: "reasoning", text: "Let me think about this..." },
// No following output - this is orphaned reasoning
],
},
];
const result = prepareStepMessages(
{ messages },
pendingUserMessages,
allInjectedMessages,
);
// Should return modified options with orphaned reasoning filtered
expect(result).toBeDefined();
expect(result!.messages).toHaveLength(2);
// The assistant message should have empty content after filtering
expect((result!.messages[1].content as any[]).length).toBe(0);
});
it("preserves reasoning when followed by text output", () => {
const pendingUserMessages: UserMessageContentPart[][] = [];
const allInjectedMessages: InjectedMessage[] = [];
const messages: ModelMessage[] = [
{ role: "user", content: "Help me" },
{
role: "assistant",
content: [
{ type: "reasoning", text: "Thinking..." },
{ type: "text", text: "Here is my response" },
],
},
];
const result = prepareStepMessages(
{ messages },
pendingUserMessages,
allInjectedMessages,
);
// No filtering needed, so should return undefined (no modifications)
expect(result).toBeUndefined();
});
it("preserves reasoning when followed by tool-call", () => {
const pendingUserMessages: UserMessageContentPart[][] = [];
const allInjectedMessages: InjectedMessage[] = [];
const messages: ModelMessage[] = [
{ role: "user", content: "Read the file" },
{
role: "assistant",
content: [
{ type: "reasoning", text: "I should read this file..." },
{
type: "tool-call",
toolCallId: "call-123",
toolName: "read_file",
input: { path: "/test.ts" },
},
],
},
];
const result = prepareStepMessages(
{ messages },
pendingUserMessages,
allInjectedMessages,
);
// No filtering needed
expect(result).toBeUndefined();
});
it("filters trailing reasoning after output", () => {
const pendingUserMessages: UserMessageContentPart[][] = [];
const allInjectedMessages: InjectedMessage[] = [];
const messages: ModelMessage[] = [
{ role: "user", content: "Help me" },
{
role: "assistant",
content: [
{ type: "text", text: "Here is my response" },
{ type: "reasoning", text: "Orphaned trailing reasoning" },
],
},
];
const result = prepareStepMessages(
{ messages },
pendingUserMessages,
allInjectedMessages,
);
// Should filter the trailing reasoning
expect(result).toBeDefined();
expect((result!.messages[1].content as any[]).length).toBe(1);
expect((result!.messages[1].content as any[])[0].type).toBe("text");
});
it("strips itemId from provider metadata during multi-step flow", () => {
const pendingUserMessages: UserMessageContentPart[][] = [];
const allInjectedMessages: InjectedMessage[] = [];
const messages: ModelMessage[] = [
{ role: "user", content: "Help me" },
{
role: "assistant",
content: [
{
type: "text",
text: "Here is my response",
providerOptions: {
openai: { itemId: "msg_abc123" },
},
},
],
},
];
const result = prepareStepMessages(
{ messages },
pendingUserMessages,
allInjectedMessages,
);
// Should strip itemId
expect(result).toBeDefined();
const textPart = (result!.messages[1].content as any[])[0];
expect(textPart.text).toBe("Here is my response");
expect(textPart.providerOptions).toBeUndefined();
});
it("strips itemId from reasoning parts while preserving reasoningEncryptedContent", () => {
const pendingUserMessages: UserMessageContentPart[][] = [];
const allInjectedMessages: InjectedMessage[] = [];
const messages: ModelMessage[] = [
{ role: "user", content: "Help me" },
{
role: "assistant",
content: [
{
type: "reasoning",
text: "Thinking...",
providerOptions: {
openai: {
itemId: "rs_abc123",
reasoningEncryptedContent: "encrypted-data",
},
},
},
{ type: "text", text: "Here is my response" },
],
},
];
const result = prepareStepMessages(
{ messages },
pendingUserMessages,
allInjectedMessages,
);
// Should strip itemId but preserve reasoningEncryptedContent
expect(result).toBeDefined();
const reasoningPart = (result!.messages[1].content as any[])[0];
expect(reasoningPart.text).toBe("Thinking...");
expect(reasoningPart.providerOptions.openai.itemId).toBeUndefined();
expect(
reasoningPart.providerOptions.openai.reasoningEncryptedContent,
).toBe("encrypted-data");
});
});
});
......@@ -11,32 +11,13 @@ const logger = log.scope("ai_messages_utils");
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.
* Strip itemId from a content part's provider metadata.
* Returns true if any itemId was stripped (mutates the part in place).
*/
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 {
function stripItemIdFromPart(part: Record<string, unknown>): boolean {
let didStrip = false;
for (const field of ["providerOptions", "providerMetadata"] as const) {
const container = obj[field];
const container = part[field];
if (!container || typeof container !== "object") continue;
const containerRecord = container as Record<
......@@ -51,6 +32,7 @@ function stripItemIdFromObject(obj: Record<string, unknown>): void {
"itemId" in providerData
) {
delete providerData.itemId;
didStrip = true;
// Clean up empty provider data
if (Object.keys(providerData).length === 0) {
delete containerRecord[key];
......@@ -59,9 +41,74 @@ function stripItemIdFromObject(obj: Record<string, unknown>): void {
}
// Clean up empty container
if (Object.keys(containerRecord).length === 0) {
delete obj[field];
delete part[field];
}
}
return didStrip;
}
/**
* Clean up a message's content parts for OpenAI compatibility:
* 1. Strip itemId from provider metadata (prevents "Item with id not found" errors)
* 2. Filter orphaned reasoning parts (prevents "reasoning without following item" errors)
*
* When messages contain `providerMetadata.openai.itemId` values, the AI SDK converts
* these to `item_reference` payloads. If OpenAI has expired those items, this causes
* "Item with id 'rs_...' not found" errors.
*
* Additionally, OpenAI's Responses API requires that reasoning items are always
* followed by an output item (text, tool-call, etc.). If a reasoning item appears
* at the end of a message without a following output, OpenAI returns:
* "Item of type 'reasoning' was provided without its required following item."
*
* Returns the original message if no changes were needed, or a new message with cleaned content.
*/
export function cleanMessageForOpenAI<T extends ModelMessage>(message: T): T {
if (typeof message.content === "string" || !Array.isArray(message.content)) {
return message;
}
const cleanedContent = [];
let didModify = false;
for (let i = 0; i < message.content.length; i++) {
const part = message.content[i] as { type?: string } & Record<
string,
unknown
>;
// Check if this is orphaned reasoning (no following output)
if (part.type === "reasoning") {
const hasFollowingOutput = message.content
.slice(i + 1)
.some((p) => (p as { type?: string }).type !== "reasoning");
if (!hasFollowingOutput) {
// Skip orphaned reasoning
didModify = true;
continue;
}
}
// Strip itemId from provider metadata
if (stripItemIdFromPart(part)) {
didModify = true;
}
cleanedContent.push(part);
}
if (!didModify) {
return message;
}
return { ...message, content: cleanedContent } as T;
}
/**
* Clean all messages in an array for OpenAI compatibility.
*/
function cleanMessagesForOpenAI(messages: ModelMessage[]): ModelMessage[] {
return messages.map(cleanMessageForOpenAI);
}
/** Maximum size in bytes for ai_messages_json (10MB) */
......@@ -116,7 +163,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
Array.isArray(parsed) &&
parsed.every((m) => m && typeof m.role === "string")
) {
return stripItemIds(parsed);
return cleanMessagesForOpenAI(parsed);
}
if (
......@@ -130,7 +177,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
(m: ModelMessage) => m && typeof m.role === "string",
)
) {
return stripItemIds((parsed as AiMessagesJsonV6).messages);
return cleanMessagesForOpenAI((parsed as AiMessagesJsonV6).messages);
}
}
......
......@@ -31,6 +31,7 @@ export function getExtraProviderOptions(
effort: "medium",
},
include: ["reasoning.encrypted_content"],
store: false,
};
}
return { reasoning_effort: "medium" };
......
......@@ -7,6 +7,7 @@
import { ImagePart, ModelMessage, TextPart, UserModelMessage } from "ai";
import type { UserMessageContentPart } from "./tools/types";
import { cleanMessageForOpenAI } from "@/ipc/utils/ai_messages_utils";
/**
* A message that has been processed and is ready to inject.
......@@ -120,17 +121,29 @@ export function prepareStepMessages<
messages.length,
);
// If no messages to inject, don't modify
if (allInjectedMessages.length === 0) {
// Clean messages for OpenAI compatibility during multi-step agent flows:
// 1. Strip itemId to prevent "Item with id not found" errors
// 2. Filter orphaned reasoning to prevent "reasoning without following item" errors
const filteredMessages = messages.map(cleanMessageForOpenAI);
// Check if we need to return modified options
const hasInjections = allInjectedMessages.length > 0;
const hasFilteredContent = filteredMessages.some(
(msg, i) => msg !== messages[i],
);
if (!hasInjections && !hasFilteredContent) {
return undefined;
}
// Build the new messages array with injections
// Cast is safe because InjectedMessage["message"] is a valid ModelMessage
const newMessages = injectMessagesAtPositions(
messages,
const newMessages = hasInjections
? (injectMessagesAtPositions(
filteredMessages,
allInjectedMessages,
) as TMessage[];
) as TMessage[])
: filteredMessages;
return { messages: newMessages, ...rest };
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论