diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9925e111815..3cf72c16b0e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", + "explicit": { "responses-call-id-linkage.test.ts": "responses", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "owner-registry-isolation.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f5047535934..677e80e4afb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { - "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", + "responses-call-id-linkage.test.ts": "responses", +"gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", diff --git a/tests/responses/responses-call-id-linkage.test.ts b/tests/responses/responses-call-id-linkage.test.ts new file mode 100644 index 00000000000..78bd6d72642 --- /dev/null +++ b/tests/responses/responses-call-id-linkage.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, test } from "bun:test"; +import { parseRequest } from "../../src/responses/parser"; +import { buildResponseJSON } from "../../src/bridge"; +import type { AdapterEvent } from "../../src/types"; +describe("Issue #6469: Responses Request Parser & Serializer Offline Regression", () => { + describe("B. Call ID preservation and linkage", () => { + test("preserves call_id across serialization and links toolResult correctly", () => { + const callId = "call_custom_unique_id_xyz"; + const events: AdapterEvent[] = [ + { + type: "tool_call_start", + id: callId, + name: "fetch_record", + }, + { + type: "tool_call_delta", + arguments: JSON.stringify({ record_id: "REC-42" }), + }, + { + type: "tool_call_end", + }, + { type: "done" }, + ]; + + const serialized = buildResponseJSON(events, "claude-3-7-sonnet"); + const output = serialized.output as Array>; + expect(output[0].call_id).toBe(callId); + expect(output[0].type).toBe("function_call"); + expect(output[0].name).toBe("fetch_record"); + + // Inbound continuation with explicit input items + const rawInbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Fetch record 42" }, + { + type: "function_call", + call_id: callId, + name: "fetch_record", + arguments: JSON.stringify({ record_id: "REC-42" }), + }, + { + type: "function_call_output", + call_id: callId, + output: JSON.stringify({ title: "Annual Report" }), + }, + ], + }; + + const parsed = parseRequest(rawInbound); + const messages = parsed.context.messages; + expect(messages.length).toBe(3); + + const assistantMsg = messages[1]; + expect(assistantMsg.role).toBe("assistant"); + const toolCallPart = (assistantMsg.content as Array>).find(p => p.type === "toolCall"); + expect(toolCallPart?.id).toBe(callId); + expect(toolCallPart?.name).toBe("fetch_record"); + + const toolResultMsg = messages[2] as { role: string; toolCallId: string; toolName: string; content: string }; + expect(toolResultMsg.role).toBe("toolResult"); + expect(toolResultMsg.toolCallId).toBe(callId); + expect(toolResultMsg.toolName).toBe("fetch_record"); + }); + }); + + describe("C. Invalid Call ID cases", () => { + test("handles unknown call_id without throwing, defaulting toolName to empty", () => { + const inbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Previous question" }, + { + type: "function_call_output", + call_id: "call_non_existent_999", + output: "some result", + }, + ], + }; + + const parsed = parseRequest(inbound); + const toolResult = parsed.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId: string; + toolName: string; + content: string; + }; + expect(toolResult).toBeDefined(); + expect(toolResult.toolCallId).toBe("call_non_existent_999"); + expect(toolResult.toolName).toBe(""); // Unlinked call produces empty toolName + }); + + test("handles duplicate call IDs accurately across single-turn and multi-turn histories", () => { + const duplicateId = "call_dup_777"; + + // 1. Single assistant turn (consecutive function_calls): + // findToolById walks backward over messages, but iterates forward across m.content parts, + // so within the same assistant turn it binds to the first tool_call with that ID. + const sameTurnInbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Start" }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v1", + arguments: "{}", + }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v2", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: duplicateId, + output: "output for tool", + }, + ], + }; + + const parsedSameTurn = parseRequest(sameTurnInbound); + const sameTurnResult = parsedSameTurn.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId: string; + toolName: string; + }; + expect(sameTurnResult).toBeDefined(); + expect(sameTurnResult.toolCallId).toBe(duplicateId); + expect(sameTurnResult.toolName).toBe("tool_v1"); // First part in m.content + + // 2. Multi-turn history (distinct assistant turns separated by user message): + // findToolById walks backward over messages, so it binds to the most recent assistant turn's call. + const multiTurnInbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Start" }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v1", + arguments: "{}", + }, + { role: "user", content: "Retry with newer tool" }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v2", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: duplicateId, + output: "output for tool", + }, + ], + }; + + const parsedMultiTurn = parseRequest(multiTurnInbound); + const multiTurnResult = parsedMultiTurn.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId: string; + toolName: string; + }; + expect(multiTurnResult).toBeDefined(); + expect(multiTurnResult.toolCallId).toBe(duplicateId); + expect(multiTurnResult.toolName).toBe("tool_v2"); // Most recent assistant turn + }); + + test("tolerates missing call_id in function_call and function_call_output with undefined id", () => { + // schema catch-all (z.object({ type: z.string() }).loose()) matches items with missing call_id, + // so parseRequest does NOT throw; call_id simply resolves to undefined on the parsed part. + const missingCallIdItem = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Test" }, + { + type: "function_call", + name: "lookup_tool", + arguments: "{}", + // call_id omitted + }, + ], + }; + + const parsed = parseRequest(missingCallIdItem); + const assistantMsg = parsed.context.messages.find(m => m.role === "assistant"); + expect(assistantMsg).toBeDefined(); + const toolCallPart = (assistantMsg?.content as Array>).find(p => p.type === "toolCall"); + expect(toolCallPart?.id).toBeUndefined(); + + // function_call_output without call_id likewise resolves to an undefined toolCallId. + const missingCallIdOutput = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Test" }, + { + type: "function_call_output", + output: "output without call_id", + }, + ], + }; + const parsedOutput = parseRequest(missingCallIdOutput); + const toolResult = parsedOutput.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId?: string; + toolName: string; + }; + expect(toolResult).toBeDefined(); + expect(toolResult.toolCallId).toBeUndefined(); + expect(toolResult.toolName).toBe(""); + }); + + test("handles mismatched call_id without corrupting sibling calls", () => { + const inbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Fetch" }, + { + type: "function_call", + call_id: "call_alpha", + name: "alpha_tool", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: "call_beta", // Mismatched + output: "output for beta", + }, + ], + }; + + const parsed = parseRequest(inbound); + const messages = parsed.context.messages; + const alphaPart = (messages[1].content as Array>).find(p => p.type === "toolCall"); + expect(alphaPart?.id).toBe("call_alpha"); + expect(alphaPart?.name).toBe("alpha_tool"); + + const betaResult = messages[2] as { role: string; toolCallId: string; toolName: string }; + expect(betaResult.toolCallId).toBe("call_beta"); + expect(betaResult.toolName).toBe(""); + }); + }); +});