From 202d7af85594add5fcdd30fcddd7adc8816bb3ab Mon Sep 17 00:00:00 2001 From: Roshan Singh Date: Sat, 24 Jan 2026 04:48:25 +0000 Subject: [PATCH] Fix OpenAI Responses transcript after model switch --- ...-helpers.downgradeopenai-reasoning.test.ts | 62 +++++++++++++ src/agents/pi-embedded-helpers.ts | 2 + src/agents/pi-embedded-helpers/openai.ts | 87 +++++++++++++++++++ src/agents/pi-embedded-runner/google.ts | 9 +- 4 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 src/agents/pi-embedded-helpers.downgradeopenai-reasoning.test.ts create mode 100644 src/agents/pi-embedded-helpers/openai.ts diff --git a/src/agents/pi-embedded-helpers.downgradeopenai-reasoning.test.ts b/src/agents/pi-embedded-helpers.downgradeopenai-reasoning.test.ts new file mode 100644 index 000000000..7d25a6727 --- /dev/null +++ b/src/agents/pi-embedded-helpers.downgradeopenai-reasoning.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers.js"; + +describe("downgradeOpenAIReasoningBlocks", () => { + it("downgrades orphaned reasoning signatures to text", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), + }, + { type: "text", text: "answer" }, + ], + }, + ]; + + expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "internal reasoning" }, { type: "text", text: "answer" }], + }, + ]); + }); + + it("drops empty thinking blocks with orphaned signatures", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: " ", + thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }), + }, + ], + }, + { role: "user", content: "next" }, + ]; + + expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([{ role: "user", content: "next" }]); + }); + + it("keeps non-reasoning thinking signatures", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "t", + thinkingSignature: "reasoning_content", + }, + ], + }, + ]; + + expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input); + }); +}); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 01cb90ef7..6f6bb474f 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -31,6 +31,8 @@ export { parseImageDimensionError, } from "./pi-embedded-helpers/errors.js"; export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js"; + +export { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers/openai.js"; export { isEmptyAssistantMessageContent, sanitizeSessionMessagesImages, diff --git a/src/agents/pi-embedded-helpers/openai.ts b/src/agents/pi-embedded-helpers/openai.ts new file mode 100644 index 000000000..5c92676be --- /dev/null +++ b/src/agents/pi-embedded-helpers/openai.ts @@ -0,0 +1,87 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; + +type OpenAIThinkingBlock = { + type?: unknown; + thinking?: unknown; + thinkingSignature?: unknown; +}; + +function isOrphanedOpenAIReasoningSignature(signature: string): boolean { + const trimmed = signature.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false; + try { + const parsed = JSON.parse(trimmed) as { id?: unknown; type?: unknown }; + const id = typeof parsed?.id === "string" ? parsed.id : ""; + const type = typeof parsed?.type === "string" ? parsed.type : ""; + if (!id.startsWith("rs_")) return false; + if (type === "reasoning") return true; + if (type.startsWith("reasoning.")) return true; + return false; + } catch { + return false; + } +} + +/** + * OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id + * without the required following item. + * + * Clawdbot persists provider-specific reasoning metadata in `thinkingSignature`; if that metadata + * is incomplete, we downgrade the block to plain text (or drop it if empty) to keep history usable. + */ +export function downgradeOpenAIReasoningBlocks(messages: AgentMessage[]): AgentMessage[] { + const out: AgentMessage[] = []; + + for (const msg of messages) { + if (!msg || typeof msg !== "object") { + out.push(msg); + continue; + } + + const role = (msg as { role?: unknown }).role; + if (role !== "assistant") { + out.push(msg); + continue; + } + + const assistantMsg = msg as Extract; + if (!Array.isArray(assistantMsg.content)) { + out.push(msg); + continue; + } + + let changed = false; + type AssistantContentBlock = (typeof assistantMsg.content)[number]; + + const nextContent = assistantMsg.content.flatMap((block): AssistantContentBlock[] => { + if (!block || typeof block !== "object") return [block as AssistantContentBlock]; + + const record = block as OpenAIThinkingBlock; + if (record.type !== "thinking") return [block as AssistantContentBlock]; + + const signature = typeof record.thinkingSignature === "string" ? record.thinkingSignature : ""; + if (!signature || !isOrphanedOpenAIReasoningSignature(signature)) { + return [block as AssistantContentBlock]; + } + + const thinking = typeof record.thinking === "string" ? record.thinking : ""; + const trimmed = thinking.trim(); + changed = true; + if (!trimmed) return []; + return [{ type: "text" as const, text: thinking }]; + }); + + if (!changed) { + out.push(msg); + continue; + } + + if (nextContent.length === 0) { + continue; + } + + out.push({ ...assistantMsg, content: nextContent } as AgentMessage); + } + + return out; +} diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index 605e9b10b..9566593b9 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -6,6 +6,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent"; import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js"; import { + downgradeOpenAIReasoningBlocks, isCompactionFailureError, isGoogleModelApi, sanitizeGoogleTurnOrdering, @@ -292,12 +293,16 @@ export async function sanitizeSessionHistory(params: { ? sanitizeToolUseResultPairing(sanitizedThinking) : sanitizedThinking; + const isOpenAIResponsesApi = + params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses"; + const sanitizedOpenAI = isOpenAIResponsesApi ? downgradeOpenAIReasoningBlocks(repairedTools) : repairedTools; + if (!policy.applyGoogleTurnOrdering) { - return repairedTools; + return sanitizedOpenAI; } return applyGoogleTurnOrderingFix({ - messages: repairedTools, + messages: sanitizedOpenAI, modelApi: params.modelApi, sessionManager: params.sessionManager, sessionId: params.sessionId,