Merge PR #7743: fix(agent): strip output statuses from remote compaction (@roboomp)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user