Merge PR #6513: fix(ai): strip output-only statuses from Responses replay (@Ant39140)

This commit is contained in:
can1357
2026-07-26 15:38:19 +02:00
3 changed files with 39 additions and 10 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed OpenAI Responses native history replay sending output-only `status` fields back as input, preventing `input[N].status` failures in long-running sessions. ([#6513](https://github.com/can1357/oh-my-pi/pull/6513) by [@Ant39140](https://github.com/Ant39140))
### Added
- OAuth logins now stamp `authorizedAt` (epoch ms of the interactive login) on the stored credential, and every refresh-persist path preserves it. Anthropic expires the whole OAuth grant family ~30 days after authorization regardless of refresh-token rotation (observed as `invalid_grant: "Refresh token expired"` on the latest rotated token, exactly 30 days after login, across four production accounts), so the login anchor is what makes re-login deadlines computable. Exported `ANTHROPIC_OAUTH_GRANT_TTL_MS` alongside the anthropic OAuth flow.
+5 -5
View File
@@ -203,9 +203,12 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
if (item.type === "image_generation_call") return sanitizeOpenAIResponsesImageGenerationCallForReplay(item);
if (item.type === "reasoning") return sanitizeOpenAIResponsesReasoningItemForReplay(item);
// Provider payload stores raw output items. Computer calls retain their stable
// provider item ID; other replay items strip IDs and normalize call_id.
// 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);
@@ -224,9 +227,6 @@ function sanitizeOpenAIResponsesReasoningItemForReplay(item: Record<string, unkn
if (typeof item.encrypted_content === "string" || item.encrypted_content === null) {
sanitizedItem.encrypted_content = item.encrypted_content;
}
if (item.status === "in_progress" || item.status === "completed" || item.status === "incomplete") {
sanitizedItem.status = item.status;
}
return sanitizedItem as unknown as OpenAIResponsesReplayItem;
}
@@ -944,9 +944,9 @@ describe("OpenAI responses history payload", () => {
const payload = (await captureResponsesPayload(model, incrementalContext)) as { input?: unknown[] };
expect(payload.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "first question" }] },
...incrementalItems1.map(({ id: _id, ...item }) => item),
...incrementalItems1.map(({ id: _id, status: _status, ...item }) => item),
{ role: "user", content: [{ type: "input_text", text: "second question" }] },
...incrementalItems2.map(({ id: _id, ...item }) => item),
...incrementalItems2.map(({ id: _id, status: _status, ...item }) => item),
{ role: "user", content: [{ type: "input_text", text: "third question" }] },
]);
});
@@ -1125,13 +1125,15 @@ describe("OpenAI responses history payload", () => {
]);
});
it("strips replay-only ids and item references while preserving paired call_id values", async () => {
it("strips output-only replay metadata while preserving paired call_id values", async () => {
const opaqueReasoningId = `item_${"copilot/reasoning+token=".repeat(8)}`;
const opaqueMessageId = `item_${"copilot/message+opaque=".repeat(8)}`;
const opaqueCallId = `call_${"copilot/tool-call+opaque/=".repeat(8)}`;
const opaqueFunctionItemId = `item_${"copilot/function-item+opaque/=".repeat(8)}`;
const opaqueCustomCallId = `call_${"copilot/custom-call+opaque/=".repeat(8)}`;
const opaqueCustomItemId = `item_${"copilot/custom-item+opaque/=".repeat(8)}`;
const replayHistoryItems: Array<Record<string, unknown>> = [
{ type: "reasoning", id: opaqueReasoningId, encrypted_content: "enc_opaque" },
{ type: "reasoning", id: opaqueReasoningId, encrypted_content: "enc_opaque", status: "completed" },
{
type: "message",
role: "assistant",
@@ -1148,6 +1150,19 @@ describe("OpenAI responses history payload", () => {
status: "completed",
},
{ type: "function_call_output", id: "fco_should_be_removed", call_id: opaqueCallId, output: "72F" },
{
type: "custom_tool_call",
id: opaqueCustomItemId,
call_id: opaqueCustomCallId,
name: "apply_patch",
input: "*** Begin Patch\n*** End Patch\n",
status: "completed",
},
{
type: "custom_tool_call_output",
call_id: opaqueCustomCallId,
output: "patch applied",
},
{ type: "item_reference", id: opaqueMessageId },
];
const context: Context = {
@@ -1163,6 +1178,7 @@ describe("OpenAI responses history payload", () => {
const messageItem = findResponsesInputItem(payload.input, "message");
const functionCallItem = findResponsesInputItem(payload.input, "function_call");
const functionCallOutputItem = findResponsesInputItem(payload.input, "function_call_output");
const customToolCallItem = findResponsesInputItem(payload.input, "custom_tool_call");
const itemReference = findResponsesInputItem(payload.input, "item_reference");
const expectedCallId = truncateResponseItemId(opaqueCallId, "call");
@@ -1170,11 +1186,16 @@ describe("OpenAI responses history payload", () => {
expect(messageItem).toBeDefined();
expect(functionCallItem).toBeDefined();
expect(functionCallOutputItem).toBeDefined();
expect(customToolCallItem).toBeDefined();
expect(reasoningItem?.id).toBeUndefined();
expect(messageItem?.id).toBeUndefined();
expect(functionCallItem?.id).toBeUndefined();
expect(functionCallOutputItem?.id).toBeUndefined();
expect(itemReference).toBeUndefined();
expect(reasoningItem).not.toHaveProperty("status");
expect(messageItem).not.toHaveProperty("status");
expect(functionCallItem).not.toHaveProperty("status");
expect(customToolCallItem).not.toHaveProperty("status");
expect(
(payload.input ?? []).some(
item => item && typeof item === "object" && "id" in (item as Record<string, unknown>),
@@ -1184,15 +1205,19 @@ describe("OpenAI responses history payload", () => {
expect(functionCallItem).toBeDefined();
expect(functionCallItem!.call_id).toBe(expectedCallId);
expect(functionCallOutputItem?.call_id).toBe(expectedCallId);
expect(customToolCallItem?.call_id).toBe(truncateResponseItemId(opaqueCustomCallId, "call"));
expect((functionCallItem!.call_id as string).length).toBeLessThanOrEqual(64);
expect(containsAssistantOutputText(payload.input, "Sanitized assistant answer")).toBe(true);
expect(replayHistoryItems[0]?.id).toBe(opaqueReasoningId);
expect(replayHistoryItems[1]?.id).toBe(opaqueMessageId);
expect(replayHistoryItems[2]?.id).toBe(opaqueFunctionItemId);
expect(replayHistoryItems[2]?.call_id).toBe(opaqueCallId);
expect(replayHistoryItems[1]?.status).toBe("completed");
expect(replayHistoryItems[2]?.status).toBe("completed");
expect(replayHistoryItems[3]?.id).toBe("fco_should_be_removed");
expect(replayHistoryItems[3]?.call_id).toBe(opaqueCallId);
expect(replayHistoryItems[4]?.id).toBe(opaqueMessageId);
expect(replayHistoryItems[4]?.status).toBe("completed");
expect(replayHistoryItems[6]?.id).toBe(opaqueMessageId);
});
it("backward compat: old full-snapshot payloads still replace history for legacy same-provider assistant turns", async () => {