fix(ai): omitted stale responses replay ids
Dropped rebuilt OpenAI Responses assistant item IDs when a replayed turn lacks its matching reasoning item while preserving text and call_id pairing. Added regression coverage for message, function_call, and custom_tool_call replay.\n\nFixes #4173
This commit is contained in:
@@ -11,6 +11,9 @@
|
||||
- Fixed an issue where broker usage fetch failures were not cached, causing sequential ranking passes to repeatedly hit the broker when it is down.
|
||||
- Fixed Xiaomi MiMo API key validation to use the supported `mimo-v2.5` model.
|
||||
- Fixed certificate verification errors for custom gateways behind private CA bundles by applying `NODE_EXTRA_CA_CERTS` to all provider fetches (including OpenAI-compatible, Codex, Ollama, Azure, and Google).
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Responses replay emitting locally rebuilt assistant item IDs without their required reasoning items, preventing `function_call` / `message` replay 400s from poisoned history. ([#4173](https://github.com/can1357/oh-my-pi/issues/4173))
|
||||
|
||||
## [16.2.13] - 2026-07-01
|
||||
|
||||
|
||||
@@ -1456,6 +1456,21 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
||||
return repairOrphanResponsesToolCalls(withRepairedOutputs);
|
||||
}
|
||||
|
||||
type ResponsesReplayAssistantMessage = Omit<ResponseOutputMessage, "id"> & { id?: string };
|
||||
|
||||
function parseResponseReasoningReplayItem(signature: string | undefined): ResponseReasoningItem | undefined {
|
||||
if (!signature) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(signature) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return undefined;
|
||||
if (!("type" in parsed) || parsed.type !== "reasoning") return undefined;
|
||||
if (!("id" in parsed) || typeof parsed.id !== "string") return undefined;
|
||||
return parsed as ResponseReasoningItem;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function convertResponsesAssistantMessage<TApi extends Api>(
|
||||
assistantMsg: AssistantMessage,
|
||||
model: Model<TApi>,
|
||||
@@ -1466,6 +1481,12 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
||||
): ResponseInput {
|
||||
const outputItems: ResponseInput = [];
|
||||
let unsignedTextBlocks = 0;
|
||||
const hasReplayableReasoningItem =
|
||||
includeThinkingSignatures &&
|
||||
assistantMsg.stopReason !== "error" &&
|
||||
assistantMsg.content.some(
|
||||
block => block.type === "thinking" && parseResponseReasoningReplayItem(block.thinkingSignature) !== undefined,
|
||||
);
|
||||
const isDifferentModel =
|
||||
assistantMsg.model !== model.id && assistantMsg.provider === model.provider && assistantMsg.api === model.api;
|
||||
|
||||
@@ -1474,14 +1495,8 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
||||
if (!includeThinkingSignatures) {
|
||||
continue;
|
||||
}
|
||||
if (block.thinkingSignature) {
|
||||
try {
|
||||
outputItems.push(JSON.parse(block.thinkingSignature) as ResponseReasoningItem);
|
||||
} catch {
|
||||
// Legacy/corrupt persisted signature — skip the reasoning item
|
||||
// rather than failing the whole request build.
|
||||
}
|
||||
}
|
||||
const reasoningItem = parseResponseReasoningReplayItem(block.thinkingSignature);
|
||||
if (reasoningItem) outputItems.push(reasoningItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1489,21 +1504,26 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
||||
const parsedSignature = parseTextSignature(block.textSignature);
|
||||
let msgId = parsedSignature?.id;
|
||||
if (!msgId) {
|
||||
// Distinct ids per unsigned block: several text blocks in one message
|
||||
// (cross-provider replay downgrades thinking → text) must not share an id.
|
||||
msgId = unsignedTextBlocks === 0 ? `msg_${msgIndex}` : `msg_${msgIndex}_${unsignedTextBlocks}`;
|
||||
unsignedTextBlocks += 1;
|
||||
if (hasReplayableReasoningItem) {
|
||||
// Distinct ids per unsigned block: several text blocks in one message
|
||||
// (cross-provider replay downgrades thinking → text) must not share an id.
|
||||
msgId = unsignedTextBlocks === 0 ? `msg_${msgIndex}` : `msg_${msgIndex}_${unsignedTextBlocks}`;
|
||||
unsignedTextBlocks += 1;
|
||||
}
|
||||
} else if (!hasReplayableReasoningItem && msgId.startsWith("msg_")) {
|
||||
msgId = undefined;
|
||||
} else if (msgId.length > 64) {
|
||||
msgId = `msg_${Bun.hash(msgId).toString(36)}`;
|
||||
}
|
||||
outputItems.push({
|
||||
const messageItem: ResponsesReplayAssistantMessage = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: block.text.toWellFormed(), annotations: [] }],
|
||||
status: "completed",
|
||||
id: msgId,
|
||||
phase: parsedSignature?.phase,
|
||||
} satisfies ResponseOutputMessage);
|
||||
...(msgId ? { id: msgId } : {}),
|
||||
...(parsedSignature?.phase ? { phase: parsedSignature.phase } : {}),
|
||||
};
|
||||
outputItems.push(messageItem as ResponseInput[number]);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1513,7 +1533,15 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
||||
|
||||
const normalized = normalizeResponsesToolCallId(block.id, block.customWireName ? "ctc" : "fc");
|
||||
let itemId: string | undefined = normalized.itemId;
|
||||
if (isDifferentModel && (itemId?.startsWith("fc_") || itemId?.startsWith("fcr_") || itemId?.startsWith("ctc_"))) {
|
||||
if (
|
||||
!hasReplayableReasoningItem &&
|
||||
(itemId?.startsWith("fc_") || itemId?.startsWith("fcr_") || itemId?.startsWith("ctc_"))
|
||||
) {
|
||||
itemId = undefined;
|
||||
} else if (
|
||||
isDifferentModel &&
|
||||
(itemId?.startsWith("fc_") || itemId?.startsWith("fcr_") || itemId?.startsWith("ctc_"))
|
||||
) {
|
||||
itemId = undefined;
|
||||
}
|
||||
knownCallIds.add(normalized.callId);
|
||||
@@ -1522,7 +1550,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
||||
customCallIds?.add(normalized.callId);
|
||||
outputItems.push({
|
||||
type: "custom_tool_call",
|
||||
id: itemId,
|
||||
...(itemId ? { id: itemId } : {}),
|
||||
call_id: normalized.callId,
|
||||
name: block.customWireName,
|
||||
input: rawInput,
|
||||
@@ -1531,7 +1559,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
||||
}
|
||||
outputItems.push({
|
||||
type: "function_call",
|
||||
id: itemId,
|
||||
...(itemId ? { id: itemId } : {}),
|
||||
call_id: normalized.callId,
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.arguments),
|
||||
|
||||
@@ -597,7 +597,7 @@ describe("dispatcher wire-name matching", () => {
|
||||
});
|
||||
|
||||
describe("history replay: custom_tool_call round-trip", () => {
|
||||
test("assistant tool-call block with customWireName replays as custom_tool_call", () => {
|
||||
test("assistant tool-call block without replayed reasoning omits custom_tool_call item id", () => {
|
||||
const assistantMsg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -630,12 +630,195 @@ describe("history replay: custom_tool_call round-trip", () => {
|
||||
expect(items).toHaveLength(1);
|
||||
const item = items[0] as { type: string; id?: string; name?: string; input?: string };
|
||||
expect(item.type).toBe("custom_tool_call");
|
||||
expect(item.id).toBe("ctc_1");
|
||||
expect(item.id).toBeUndefined();
|
||||
expect(item.name).toBe("apply_patch");
|
||||
expect(item.input).toBe("*** Begin Patch\n*** End Patch\n");
|
||||
expect(customCallIds.has("call_1")).toBe(true);
|
||||
});
|
||||
|
||||
test("assistant tool-call block with replayed reasoning keeps custom_tool_call item id", () => {
|
||||
const assistantMsg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
thinkingSignature: JSON.stringify({ type: "reasoning", id: "rs_1", summary: [] }),
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_1|ctc_1",
|
||||
name: "edit",
|
||||
arguments: { input: "*** Begin Patch\n*** End Patch\n" },
|
||||
customWireName: "apply_patch",
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
api: "openai-responses",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
};
|
||||
const knownCallIds = new Set<string>();
|
||||
const customCallIds = new Set<string>();
|
||||
const items = convertResponsesAssistantMessage(assistantMsg, makeModel(), 0, knownCallIds, true, customCallIds);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({ type: "reasoning", id: "rs_1" });
|
||||
expect(items[1]).toMatchObject({ type: "custom_tool_call", id: "ctc_1", call_id: "call_1" });
|
||||
expect(customCallIds.has("call_1")).toBe(true);
|
||||
});
|
||||
|
||||
test("assistant function_call block without replayed reasoning omits item id", () => {
|
||||
const assistantMsg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_1|fc_1",
|
||||
name: "read",
|
||||
arguments: { path: "README.md" },
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
api: "openai-responses",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
};
|
||||
const knownCallIds = new Set<string>();
|
||||
const items = convertResponsesAssistantMessage(assistantMsg, makeModel(), 0, knownCallIds, true);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ type: "function_call", call_id: "call_1", name: "read" });
|
||||
expect(JSON.parse(JSON.stringify(items[0]))).not.toHaveProperty("id");
|
||||
expect(knownCallIds.has("call_1")).toBe(true);
|
||||
});
|
||||
|
||||
test("assistant function_call block with replayed reasoning keeps item id", () => {
|
||||
const assistantMsg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
thinkingSignature: JSON.stringify({ type: "reasoning", id: "rs_1", summary: [] }),
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_1|fc_1",
|
||||
name: "read",
|
||||
arguments: { path: "README.md" },
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
api: "openai-responses",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
};
|
||||
const knownCallIds = new Set<string>();
|
||||
const items = convertResponsesAssistantMessage(assistantMsg, makeModel(), 0, knownCallIds, true);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({ type: "reasoning", id: "rs_1" });
|
||||
expect(items[1]).toMatchObject({ type: "function_call", id: "fc_1", call_id: "call_1" });
|
||||
expect(knownCallIds.has("call_1")).toBe(true);
|
||||
});
|
||||
|
||||
test("assistant message block without replayed reasoning omits msg item id", () => {
|
||||
const assistantMsg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "done",
|
||||
textSignature: JSON.stringify({ v: 1, id: "msg_1" }),
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
api: "openai-responses",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
};
|
||||
const knownCallIds = new Set<string>();
|
||||
const items = convertResponsesAssistantMessage(assistantMsg, makeModel(), 0, knownCallIds, true);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ type: "message", role: "assistant", status: "completed" });
|
||||
expect(JSON.parse(JSON.stringify(items[0]))).not.toHaveProperty("id");
|
||||
});
|
||||
|
||||
test("assistant message block with replayed reasoning keeps msg item id", () => {
|
||||
const assistantMsg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
thinkingSignature: JSON.stringify({ type: "reasoning", id: "rs_1", summary: [] }),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "done",
|
||||
textSignature: JSON.stringify({ v: 1, id: "msg_1" }),
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
api: "openai-responses",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
};
|
||||
const knownCallIds = new Set<string>();
|
||||
const items = convertResponsesAssistantMessage(assistantMsg, makeModel(), 0, knownCallIds, true);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({ type: "reasoning", id: "rs_1" });
|
||||
expect(items[1]).toMatchObject({ type: "message", id: "msg_1", role: "assistant", status: "completed" });
|
||||
});
|
||||
|
||||
test("custom tool call omits item id when replayed across same-provider model switch", () => {
|
||||
const assistantMsg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
|
||||
@@ -537,7 +537,6 @@ describe("OpenAI responses history payload", () => {
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "generic assistant that should be preserved", annotations: [] }],
|
||||
status: "completed",
|
||||
id: "msg_1",
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "follow-up user" }] },
|
||||
]);
|
||||
@@ -642,14 +641,13 @@ describe("OpenAI responses history payload", () => {
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Commentary answer", annotations: [] }],
|
||||
status: "completed",
|
||||
id: "msg_commentary",
|
||||
phase: "commentary",
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "follow-up" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps legacy plain-string text signatures when rebuilding fallback replay history", async () => {
|
||||
it("omits legacy plain-string text signature IDs when rebuilding fallback replay history without reasoning", async () => {
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{ role: "user", content: "first user", timestamp: Date.now() },
|
||||
@@ -682,7 +680,6 @@ describe("OpenAI responses history payload", () => {
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Legacy answer", annotations: [] }],
|
||||
status: "completed",
|
||||
id: "msg_legacy",
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "follow-up" }] },
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user