diff --git a/src/server/responses/responses-field-backfill.ts b/src/server/responses/responses-field-backfill.ts index 4d67531020..32cf727891 100644 --- a/src/server/responses/responses-field-backfill.ts +++ b/src/server/responses/responses-field-backfill.ts @@ -34,6 +34,14 @@ const ITEM_ID_PREFIXES: Readonly> = { message: "msg_", reasoning: "rs_", function_call: "fc_", + custom_tool_call: "ctc_", + // A routed tool_search lowering is restored to `tool_search_call` without an id, so this + // backfill is what names it. The generic `item_` fallback is not merely cosmetic here: + // `stripInvalidItemIds` in the Responses adapter deletes any id whose prefix does not match + // the type, so an `item_`-named tool_search_call silently loses its id on the NEXT turn and + // the client sees an item it cannot correlate. The prefixes here must stay a superset of the + // ones that serializer enforces. + tool_search_call: "tsc_", web_search_call: "ws_", file_search_call: "fs_", code_interpreter_call: "ci_", diff --git a/tests/responses-field-backfill.test.ts b/tests/responses-field-backfill.test.ts index f6fb63bae3..f4ac338b69 100644 --- a/tests/responses-field-backfill.test.ts +++ b/tests/responses-field-backfill.test.ts @@ -365,6 +365,36 @@ describe("responses-field-backfill", () => { expect(result.output[0]!.id).toBe("ig_ocx_0"); }); + // A routed tool_search lowering is restored as `tool_search_call` with no id, so this + // backfill names it. The generic `item_` fallback was not cosmetic: `stripInvalidItemIds` + // in the Responses adapter deletes an id whose prefix does not match its type, so the item + // silently lost its id on the NEXT turn and the client could no longer correlate it. + test("tool_search_call gets the prefix the request serializer accepts", () => { + const response = { + id: "resp_1", + object: "response", + status: "completed", + output: [{ type: "tool_search_call", call_id: "call_x", execution: "client" }], + }; + const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as { + output: { id: string }[]; + }; + expect(result.output[0]!.id).toBe("tsc_ocx_0"); + }); + + test("custom_tool_call gets its own prefix too", () => { + const response = { + id: "resp_1", + object: "response", + status: "completed", + output: [{ type: "custom_tool_call", call_id: "call_y" }], + }; + const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as { + output: { id: string }[]; + }; + expect(result.output[0]!.id).toBe("ctc_ocx_0"); + }); + // A malformed `output_index` falls back to a counter. While that counter lived in the same // numeric namespace as real indexes, a response whose real index reached the counter's base // produced the SAME id as a fallback — a duplicate, which is the one thing this backfill