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:
roboomp
2026-07-01 23:15:56 +02:00
committed by can1357
parent 5f1ed0fcde
commit 5244b8bdcd
4 changed files with 236 additions and 25 deletions
+3
View File
@@ -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
+47 -19
View File
@@ -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),
+185 -2
View File
@@ -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" }] },
]);