diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6966d94a..d5f1525f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions by generating unique fallback message item IDs for converted thinking/text blocks ([#5148](https://github.com/earendil-works/pi/issues/5148)). - Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)). - Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts. - Fixed OpenCode Go Kimi K2.6 thinking-off requests to send `thinking: "none"` ([#5078](https://github.com/earendil-works/pi/issues/5078)). diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index b7bcd192..d769ce19 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -166,6 +166,7 @@ export function convertResponsesMessages( assistantMsg.model !== model.id && assistantMsg.provider === model.provider && assistantMsg.api === model.api; + let textBlockIndex = 0; for (const block of msg.content) { if (block.type === "thinking") { @@ -176,10 +177,13 @@ export function convertResponsesMessages( } else if (block.type === "text") { const textBlock = block as TextContent; const parsedSignature = parseTextSignature(textBlock.textSignature); + const fallbackMessageId = + textBlockIndex === 0 ? `pi_msg_${msgIndex}` : `pi_msg_${msgIndex}_${textBlockIndex}`; + textBlockIndex++; // OpenAI requires id to be max 64 characters let msgId = parsedSignature?.id; if (!msgId) { - msgId = `msg_${msgIndex}`; + msgId = fallbackMessageId; } else if (msgId.length > 64) { msgId = `msg_${shortHash(msgId)}`; } diff --git a/packages/ai/test/openai-responses-message-id.test.ts b/packages/ai/test/openai-responses-message-id.test.ts new file mode 100644 index 00000000..07669d8c --- /dev/null +++ b/packages/ai/test/openai-responses-message-id.test.ts @@ -0,0 +1,48 @@ +import type { ResponseOutputMessage } from "openai/resources/responses/responses.js"; +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.ts"; +import { convertResponsesMessages } from "../src/providers/openai-responses-shared.ts"; +import type { AssistantMessage, Context, Usage } from "../src/types.ts"; + +const usage: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +describe("OpenAI Responses message ID conversion", () => { + it("generates unique fallback message IDs for multiple text blocks in one assistant turn", () => { + const model = getModel("openai-codex", "gpt-5.5"); + const assistant: AssistantMessage = { + role: "assistant", + content: [ + { type: "thinking", thinking: "private reasoning" }, + { type: "text", text: "visible answer" }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-opus-4-8", + usage, + stopReason: "stop", + timestamp: Date.now() - 1000, + }; + const context: Context = { + systemPrompt: "You are concise.", + messages: [{ role: "user", content: "hello", timestamp: Date.now() - 2000 }, assistant], + }; + + const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"])); + const messageIds = input + .filter( + (item): item is ResponseOutputMessage => + item.type === "message" && "id" in item && typeof item.id === "string", + ) + .map((item) => item.id); + + expect(messageIds).toEqual(["pi_msg_1", "pi_msg_1_1"]); + expect(new Set(messageIds).size).toBe(messageIds.length); + }); +});