diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index e42677ce5..72dc65f60 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed remote OpenAI Responses compaction replay sending output-only `status` fields back as input, including persisted native history and prior V1/V2 replacement history. ([#7742](https://github.com/can1357/oh-my-pi/issues/7742)) + ## [17.2.9] - 2026-08-05 ### Fixed diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 529f5d489..c9c64b09a 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -26,6 +26,7 @@ import * as AIError from "@oh-my-pi/pi-ai/error"; import { createOpenAICodexCompactionRequestContext } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { convertTools } from "@oh-my-pi/pi-ai/providers/openai-responses"; import { buildResponsesInput, resolveOpenAICompatPolicy } from "@oh-my-pi/pi-ai/providers/openai-shared"; +import { stripOpenAIResponsesOutputOnlyStatusesForReplay } from "@oh-my-pi/pi-ai/utils"; import { preferredDialect } from "@oh-my-pi/pi-catalog/identity"; import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking"; import { isRecord, logger, prompt, stringifyJson } from "@oh-my-pi/pi-utils"; @@ -1351,7 +1352,9 @@ function buildOpenAiResponsesCompactionInput( } nativeInput.push(item); } - return previousReplacementHistory ? [...previousReplacementHistory, ...nativeInput] : nativeInput; + return stripOpenAIResponsesOutputOnlyStatusesForReplay( + previousReplacementHistory ? [...previousReplacementHistory, ...nativeInput] : nativeInput, + ); } /** diff --git a/packages/agent/src/compaction/openai.ts b/packages/agent/src/compaction/openai.ts index 4fe73ad42..004c7c492 100644 --- a/packages/agent/src/compaction/openai.ts +++ b/packages/agent/src/compaction/openai.ts @@ -37,6 +37,7 @@ import { getOpenAIResponsesHistoryItems, getOpenAIResponsesHistoryPayload, normalizeResponsesToolCallId, + stripOpenAIResponsesOutputOnlyStatusesForReplay, } from "@oh-my-pi/pi-ai/utils"; import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http"; import { @@ -739,7 +740,7 @@ export function buildOpenAiNativeHistory( msgIndex++; } - return input; + return stripOpenAIResponsesOutputOnlyStatusesForReplay(input); } // ============================================================================ diff --git a/packages/agent/test/remote-compaction.test.ts b/packages/agent/test/remote-compaction.test.ts index 2a16d3419..949150a55 100644 --- a/packages/agent/test/remote-compaction.test.ts +++ b/packages/agent/test/remote-compaction.test.ts @@ -324,8 +324,22 @@ function codexAssistant(calls: Array<{ callId: string; custom?: boolean }>, dt: })); const items = calls.map(c => c.custom - ? { type: "custom_tool_call", id: `ctc_${c.callId}`, call_id: c.callId, name: "apply_patch", input: "p" } - : { type: "function_call", id: `fc_${c.callId}`, call_id: c.callId, name: "read", arguments: "{}" }, + ? { + type: "custom_tool_call", + id: `ctc_${c.callId}`, + call_id: c.callId, + name: "apply_patch", + input: "p", + status: "completed", + } + : { + type: "function_call", + id: `fc_${c.callId}`, + call_id: c.callId, + name: "read", + arguments: "{}", + status: "completed", + }, ); return { role: "assistant", @@ -358,6 +372,9 @@ describe("buildOpenAiNativeHistory call-id tracking", () => { CODEX_MODEL, ); const output = items.find(item => item.type === "function_call_output"); + const call = items.find(item => item.type === "function_call"); + expect(call).toBeDefined(); + expect(call).not.toHaveProperty("status"); expect(output?.call_id).toBe("call_1"); expect(items.find(item => item.type === "custom_tool_call_output")).toBeUndefined(); }); @@ -506,8 +523,8 @@ describe("buildOpenAiNativeHistory computer calls", () => { expect(recovery).toMatchObject({ type: "message", role: "assistant", - status: "completed", }); + expect(recovery).not.toHaveProperty("status"); expect(String(recovery?.id)).toMatch(/^msg_[a-z0-9-]+$/); expect(recovery?.content).toEqual([expect.objectContaining({ type: "output_text", annotations: [] })]); expect(JSON.stringify(items)).toContain("failed before a screenshot was recorded"); @@ -533,7 +550,8 @@ describe("buildOpenAiNativeHistory computer calls", () => { const second = buildOpenAiNativeHistory([computerAssistant(), result], unsupportedModel); expect(first).toHaveLength(2); for (const note of first) { - expect(note).toMatchObject({ type: "message", role: "assistant", status: "completed" }); + expect(note).toMatchObject({ type: "message", role: "assistant" }); + expect(note).not.toHaveProperty("status"); expect(String(note.id)).toMatch(/^msg_[a-z0-9-]+$/); expect(note.content).toEqual([expect.objectContaining({ type: "output_text", annotations: [] })]); } @@ -1791,6 +1809,13 @@ describe("compact() remote compaction failure handling", () => { expect(input.some(item => item.type === "reasoning")).toBe(true); expect(input.some(item => item.type === "function_call" && item.name === "read")).toBe(true); expect(input.some(item => item.type === "function_call_output")).toBe(true); + expect( + input.some( + item => + (item.type === "message" || item.type === "function_call" || item.type === "custom_tool_call") && + Object.hasOwn(item, "status"), + ), + ).toBe(false); // Reasoning effort is sent like a normal turn (gpt-5 is a reasoning model). expect(requestBody?.reasoning).toMatchObject({ effort: "high", summary: "auto" }); const remote = getCompactionV2PreserveData(result.preserveData); diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts index 177c9c63a..a9f914f38 100644 --- a/packages/ai/src/utils.ts +++ b/packages/ai/src/utils.ts @@ -72,6 +72,30 @@ interface OpenAIResponsesReplaySanitizeOptions { supportsImageDetailOriginal?: boolean; supportsComputerUse?: boolean; } +/** + * Removes response-only lifecycle status from item types that reject it when replayed as input. + * + * Returns the original array when no item needs sanitization. + */ +export function stripOpenAIResponsesOutputOnlyStatusesForReplay( + items: TItem[], +): TItem[] { + let sanitized: TItem[] | undefined; + for (let index = 0; index < items.length; index++) { + const item = items[index]!; + const rejectsOutputStatus = + item.type === "message" || item.type === "function_call" || item.type === "custom_tool_call"; + if (!rejectsOutputStatus || !Object.hasOwn(item, "status")) { + sanitized?.push(item); + continue; + } + if (!sanitized) sanitized = items.slice(0, index); + const withoutStatus = { ...item }; + delete withoutStatus.status; + sanitized.push(withoutStatus); + } + return sanitized ?? items; +} /** * Clamp `detail: "original"` only where Responses input_image parts live — @@ -184,19 +208,20 @@ export function sanitizeOpenAIResponsesHistoryItemsForReplay( options.supportsComputerUse === false ? undefined : collectOpenAIResponsesComputerLinkedReasoningItems(items, false); - return items.flatMap(item => { + const sanitized = items.flatMap(item => { const preserveForComputer = computerLinkedReasoningItems?.has(item) === true; - const sanitized = sanitizeOpenAIResponsesHistoryItemForReplay( + const sanitizedItem = sanitizeOpenAIResponsesHistoryItemForReplay( item, normalizedCallIds, supportsImageDetailOriginal, preserveForComputer, ); - if (preserveForComputer && sanitized?.type === "reasoning") { - provisionalOpenAIResponsesComputerReasoningItems.add(sanitized); + if (preserveForComputer && sanitizedItem?.type === "reasoning") { + provisionalOpenAIResponsesComputerReasoningItems.add(sanitizedItem); } - return sanitized ? [sanitized] : []; + return sanitizedItem ? [sanitizedItem] : []; }); + return stripOpenAIResponsesOutputOnlyStatusesForReplay(sanitized); } function collectOpenAIResponsesReasoningItemsWithSurvivingOutputIds( @@ -369,12 +394,7 @@ function sanitizeOpenAIResponsesHistoryItemForReplay( if (item.type === "reasoning") { return sanitizeOpenAIResponsesReasoningItemForReplay(item, preserveReasoningItemIds); } - // Strip status only from item types whose replay input rejects output - // lifecycle metadata. Hosted built-in tool items require status for replay. const { id: _id, ...sanitizedItem } = item; - if (item.type === "message" || item.type === "function_call" || item.type === "custom_tool_call") { - delete sanitizedItem.status; - } if (item.type === "computer_call" && typeof item.id === "string") sanitizedItem.id = item.id; if (typeof item.call_id === "string") { sanitizedItem.call_id = normalizeReplayedResponsesHistoryCallId(item.call_id, normalizedCallIds);