Merge PR #7743: fix(agent): strip output statuses from remote compaction (@roboomp)

This commit is contained in:
can1357
2026-08-05 22:15:46 +02:00
5 changed files with 69 additions and 16 deletions
+4
View File
@@ -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
+4 -1
View File
@@ -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,
);
}
/**
+2 -1
View File
@@ -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);
}
// ============================================================================
+29 -4
View File
@@ -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);
+30 -10
View File
@@ -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<TItem extends { type?: unknown; status?: unknown }>(
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);