From d3efe5e111d3614075c5bd41f4dd7b4dfd6a074f Mon Sep 17 00:00:00 2001 From: daandden <64765666+daandden@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:49:20 +0700 Subject: [PATCH] fix(packages/ai): strip replay-only responses history ids (#458) * fix(packages/ai): strip replay-only responses history ids fixes #457 * fix(packages/ai): satisfy check ts for replay sanitizer --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/providers/openai-responses.ts | 14 ++-- packages/ai/src/utils.ts | 34 ++++++++ .../openai-responses-history-payload.test.ts | 82 ++++++++++++++++++- 4 files changed, 124 insertions(+), 10 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 863f0cde2..0801f3cba 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `openai-responses` manual history replay to strip replay-only item IDs and preserve normalized tool `call_id` values for GitHub Copilot follow-up turns ([#457](https://github.com/can1357/oh-my-pi/issues/457)) + ## [13.12.0] - 2026-03-14 ### Added diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index aa8e67bf1..95682ef75 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -25,6 +25,7 @@ import { getOpenAIResponsesHistoryItems, getOpenAIResponsesHistoryPayload, resolveCacheRetention, + sanitizeOpenAIResponsesHistoryItemsForReplay, } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; @@ -362,11 +363,9 @@ function convertConversationMessages( for (const msg of transformedMessages) { if (msg.role === "user" || msg.role === "developer") { const providerPayload = (msg as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload; - const historyItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider) as - | Array - | undefined; + const historyItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider); if (historyItems) { - messages.push(...historyItems); + messages.push(...sanitizeOpenAIResponsesHistoryItemsForReplay(historyItems)); knownCallIds = collectKnownCallIds(messages); msgIndex++; continue; @@ -381,12 +380,13 @@ function convertConversationMessages( model.provider, assistantMsg.provider, ); - const historyItems = providerPayload?.items as Array | undefined; + const historyItems = providerPayload?.items; if (historyItems) { + const sanitizedHistoryItems = sanitizeOpenAIResponsesHistoryItemsForReplay(historyItems); if (providerPayload?.dt) { - messages.push(...historyItems); + messages.push(...sanitizedHistoryItems); } else { - messages.splice(0, messages.length, ...historyItems); + messages.splice(0, messages.length, ...sanitizedHistoryItems); } knownCallIds = collectKnownCallIds(messages); msgIndex++; diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts index 712f4cfdf..1f1288619 100644 --- a/packages/ai/src/utils.ts +++ b/packages/ai/src/utils.ts @@ -1,6 +1,9 @@ import { $env } from "@oh-my-pi/pi-utils"; +import type { ResponseInput } from "openai/resources/responses/responses"; import type { CacheRetention, OpenAIResponsesHistoryPayload, ProviderPayload } from "./types"; +type OpenAIResponsesReplayItem = ResponseInput[number]; + export { isRecord } from "@oh-my-pi/pi-utils"; export function toNumber(value: unknown): number | undefined { @@ -62,6 +65,37 @@ export function truncateResponseItemId(id: string, prefix: string): string { return `${prefix}_${Bun.hash.xxHash64(id).toString(36)}`; } +export function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array>): ResponseInput { + const normalizedCallIds = new Map(); + return items.flatMap(item => { + const sanitized = sanitizeOpenAIResponsesHistoryItemForReplay(item, normalizedCallIds); + return sanitized ? [sanitized] : []; + }); +} + +function sanitizeOpenAIResponsesHistoryItemForReplay( + item: Record, + normalizedCallIds: Map, +): OpenAIResponsesReplayItem | undefined { + if (item.type === "item_reference") return undefined; + + // providerPayload stores raw output items; replay strips item ids and keeps only normalized call_id. + const { id: _id, ...sanitizedItem } = item; + if (typeof item.call_id === "string") { + sanitizedItem.call_id = normalizeReplayedResponsesHistoryCallId(item.call_id, normalizedCallIds); + } + + return sanitizedItem as unknown as OpenAIResponsesReplayItem; +} + +function normalizeReplayedResponsesHistoryCallId(value: string, normalizedValues: Map): string { + const normalized = normalizedValues.get(value); + if (normalized) return normalized; + const next = truncateResponseItemId(value, getIdPrefix(value, "call")); + normalizedValues.set(value, next); + return next; +} + export function createOpenAIResponsesHistoryPayload( provider: string, items: Array>, diff --git a/packages/ai/test/openai-responses-history-payload.test.ts b/packages/ai/test/openai-responses-history-payload.test.ts index 822f4329d..ad956743c 100644 --- a/packages/ai/test/openai-responses-history-payload.test.ts +++ b/packages/ai/test/openai-responses-history-payload.test.ts @@ -3,7 +3,7 @@ import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICodexResponses } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; -import { createOpenAIResponsesHistoryPayload } from "../src/utils"; +import { createOpenAIResponsesHistoryPayload, truncateResponseItemId } from "../src/utils"; function createAbortedSignal(): AbortSignal { const controller = new AbortController(); @@ -169,6 +169,13 @@ function containsEncryptedReasoning(input: unknown[] | undefined): boolean { }); } +function findResponsesInputItem(input: unknown[] | undefined, type: string): Record | undefined { + return input?.find(item => { + if (!item || typeof item !== "object") return false; + return (item as { type?: unknown }).type === type; + }) as Record | undefined; +} + describe("OpenAI responses history payload", () => { it("inlines preserved replacement history for openai-responses", async () => { const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; @@ -206,9 +213,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, + ...incrementalItems1.map(({ id: _id, ...item }) => item), { role: "user", content: [{ type: "input_text", text: "second question" }] }, - ...incrementalItems2, + ...incrementalItems2.map(({ id: _id, ...item }) => item), { role: "user", content: [{ type: "input_text", text: "third question" }] }, ]); }); @@ -298,6 +305,75 @@ describe("OpenAI responses history payload", () => { ]); }); + it("strips replay-only ids and item references 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 replayHistoryItems: Array> = [ + { type: "reasoning", id: opaqueReasoningId, encrypted_content: "enc_opaque" }, + { + type: "message", + role: "assistant", + id: opaqueMessageId, + status: "completed", + content: [{ type: "output_text", text: "Sanitized assistant answer", annotations: [] }], + }, + { + type: "function_call", + id: opaqueFunctionItemId, + call_id: opaqueCallId, + name: "lookup_weather", + arguments: '{"city":"Oslo"}', + status: "completed", + }, + { type: "function_call_output", id: "fco_should_be_removed", call_id: opaqueCallId, output: "72F" }, + { type: "item_reference", id: opaqueMessageId }, + ]; + const context: Context = { + messages: [ + makeAssistantMessage(replayHistoryItems, false), + { role: "user", content: "follow-up user", timestamp: Date.now() }, + ], + }; + + const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; + const payload = (await captureResponsesPayload(model, context)) as { input?: unknown[] }; + const reasoningItem = findResponsesInputItem(payload.input, "reasoning"); + const messageItem = findResponsesInputItem(payload.input, "message"); + const functionCallItem = findResponsesInputItem(payload.input, "function_call"); + const functionCallOutputItem = findResponsesInputItem(payload.input, "function_call_output"); + const itemReference = findResponsesInputItem(payload.input, "item_reference"); + const expectedCallId = truncateResponseItemId(opaqueCallId, "call"); + + expect(reasoningItem).toBeDefined(); + expect(messageItem).toBeDefined(); + expect(functionCallItem).toBeDefined(); + expect(functionCallOutputItem).toBeDefined(); + expect(reasoningItem?.id).toBeUndefined(); + expect(messageItem?.id).toBeUndefined(); + expect(functionCallItem?.id).toBeUndefined(); + expect(functionCallOutputItem?.id).toBeUndefined(); + expect(itemReference).toBeUndefined(); + expect( + (payload.input ?? []).some( + item => item && typeof item === "object" && "id" in (item as Record), + ), + ).toBe(false); + expect(reasoningItem?.encrypted_content).toBe("enc_opaque"); + expect(functionCallItem?.call_id).toBe(expectedCallId); + expect(functionCallOutputItem?.call_id).toBe(expectedCallId); + 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[3]?.id).toBe("fco_should_be_removed"); + expect(replayHistoryItems[3]?.call_id).toBe(opaqueCallId); + expect(replayHistoryItems[4]?.id).toBe(opaqueMessageId); + }); + it("backward compat: old full-snapshot payloads still replace history for legacy same-provider assistant turns", async () => { const fullSnapshotItems = [ { type: "message", role: "user", content: [{ type: "input_text", text: "Canonical user" }] },