Skip to content

Commit 78c56d7

Browse files
authored
fix(gemini): round-trip functionCall.id through tool-call response serialization (#199)
## Summary `parseToolCallPart` in `src/gemini.ts` was dropping the fixture's `tool_call.id` when constructing the Gemini response body. This silently breaks any client that preserves the id across the round trip (real Gemini's session correlator, ADK-style clients, any proxy that needs to match a follow-up `functionResponse` back to the originating `functionCall`). The aimock `geminiToCompletionRequest` translator then has to fall back to `call_gemini_<name>_<i>`, which means any fixture chain keyed on a specific `toolCallId` never advances past the first leg — aimock re-matches the same `userMessage`-keyed leg-1 fixture in a loop while the client keeps sending what looks like the same context. ## Change - Add the optional `id` field on `GeminiPart.functionCall` and `GeminiPart.functionResponse` shapes. - Emit `id` on `parseToolCallPart` when the fixture sets one. Backward-compatible: fixtures without an id continue to serialize without one. ## How this was found While porting `showcase/google-adk` to D5 parity with `langgraph-python`, the `tool-rendering-reasoning-chain` demo's three chained pills (AAPL→MSFT, d20→d6, flights+weather) kept stalling after the first tool call. Dumping the request body aimock received showed the `model` content had `functionCall: { name, args }` with no `id`. Tracing back through `parseToolCallPart` revealed the id was being dropped on serialization. ## Test plan - [ ] Existing aimock tests still pass. - [ ] Curl probe with a multi-leg fixture chain (one fixture keyed on `userMessage`, follow-up keyed on `toolCallId`) advances past leg 1 when the client preserves and re-sends the id. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents c2ea032 + aeaa32a commit 78c56d7

4 files changed

Lines changed: 119 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## [Unreleased]
44

5+
## [1.24.1] - 2026-05-14
6+
7+
### Fixed
8+
9+
- **Gemini tool-call response serializer dropped fixture-pinned `tool_call.id`**`parseToolCallPart` emitted `{ functionCall: { name, args } }` and omitted the id even when the fixture pinned one. Pairs with v1.23.1's INGEST-direction fix ([#196](https://github.com/CopilotKit/aimock/pull/196)) which preserves `functionCall.id` when aimock parses an _incoming_ Gemini request — that fix only helps when the id is already in the response body. Without this EGRESS-direction fix, aimock never emits one for clients to preserve in the first place, so the round-trip silently breaks for any client that depends on `functionCall.id` to correlate follow-up `functionResponse` parts back to the originating call
10+
511
## [1.24.0] - 2026-05-14
612

713
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@copilotkit/aimock",
3-
"version": "1.24.0",
3+
"version": "1.24.1",
44
"description": "Mock infrastructure for AI application testing — LLM APIs, image generation, text-to-speech, transcription, audio generation, video generation, MCP tools, A2A agents, AG-UI event streams, vector databases, search, rerank, and moderation. One package, one port, zero dependencies.",
55
"license": "MIT",
66
"keywords": [

src/__tests__/gemini.test.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,27 @@ const multiToolFixture: Fixture = {
112112
},
113113
};
114114

115+
// Fixture with `id` pinned on the tool call. Exercises the egress
116+
// counterpart to #196's ingest fix: aimock must surface tc.id on the
117+
// Gemini `functionCall` so clients have an id to preserve across the
118+
// round trip.
119+
const toolFixtureWithId: Fixture = {
120+
match: { userMessage: "pinned-id" },
121+
response: {
122+
toolCalls: [{ id: "call_test_pinned_001", name: "get_weather", arguments: '{"city":"Tokyo"}' }],
123+
},
124+
};
125+
126+
const multiToolFixtureWithIds: Fixture = {
127+
match: { userMessage: "pinned-multi" },
128+
response: {
129+
toolCalls: [
130+
{ id: "call_test_a", name: "get_weather", arguments: '{"city":"NYC"}' },
131+
{ id: "call_test_b", name: "get_time", arguments: '{"tz":"EST"}' },
132+
],
133+
},
134+
};
135+
115136
const errorFixture: Fixture = {
116137
match: { userMessage: "fail" },
117138
response: {
@@ -133,6 +154,8 @@ const allFixtures: Fixture[] = [
133154
textFixture,
134155
toolFixture,
135156
multiToolFixture,
157+
toolFixtureWithId,
158+
multiToolFixtureWithIds,
136159
errorFixture,
137160
badResponseFixture,
138161
];
@@ -511,7 +534,11 @@ describe("POST /v1beta/models/{model}:generateContent (non-streaming)", () => {
511534
expect(body.candidates[0].content.parts[1].functionCall.name).toBe("get_time");
512535
});
513536

514-
it("functionCall parts do NOT contain an id field", async () => {
537+
it("omits functionCall.id when the fixture does not pin one (backward compatible)", async () => {
538+
// Fixtures authored before id-preservation existed didn't set tc.id;
539+
// those must continue to serialize without an id field so the
540+
// ingest-side fallback generator (`call_gemini_<name>_<i>`) handles
541+
// them on the next request. Pre-#196 fixtures rely on this.
515542
instance = await createServer(allFixtures);
516543
const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, {
517544
contents: [{ role: "user", parts: [{ text: "weather" }] }],
@@ -521,9 +548,44 @@ describe("POST /v1beta/models/{model}:generateContent (non-streaming)", () => {
521548
const fc = body.candidates[0].content.parts[0].functionCall;
522549
expect(fc.name).toBe("get_weather");
523550
expect(fc.args).toEqual({ city: "NYC" });
524-
// Gemini FunctionCall schema only has name + args — no id
525551
expect(fc).not.toHaveProperty("id");
526552
});
553+
554+
it("surfaces functionCall.id when the fixture pins tc.id", async () => {
555+
// Egress counterpart to #196's INGEST-direction fix
556+
// (v1.23.1 — preserve functionCall.id when aimock PARSES an
557+
// incoming Gemini request). That fix only helps if there's an id in
558+
// the response body to begin with; without this egress surfacing,
559+
// aimock emits `{ functionCall: { name, args } }` and clients have
560+
// nothing to round-trip. Any fixture chain that keys a follow-up on
561+
// `toolCallId` then falls through to the first-leg `userMessage`
562+
// matcher, creating an infinite loop on Gemini/ADK integrations.
563+
instance = await createServer(allFixtures);
564+
const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, {
565+
contents: [{ role: "user", parts: [{ text: "pinned-id" }] }],
566+
});
567+
568+
const body = JSON.parse(res.body);
569+
const fc = body.candidates[0].content.parts[0].functionCall;
570+
expect(fc.name).toBe("get_weather");
571+
expect(fc.args).toEqual({ city: "Tokyo" });
572+
expect(fc.id).toBe("call_test_pinned_001");
573+
});
574+
575+
it("surfaces functionCall.id on every tool call when multiple are pinned", async () => {
576+
instance = await createServer(allFixtures);
577+
const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, {
578+
contents: [{ role: "user", parts: [{ text: "pinned-multi" }] }],
579+
});
580+
581+
const body = JSON.parse(res.body);
582+
const parts = body.candidates[0].content.parts;
583+
expect(parts).toHaveLength(2);
584+
expect(parts[0].functionCall.name).toBe("get_weather");
585+
expect(parts[0].functionCall.id).toBe("call_test_a");
586+
expect(parts[1].functionCall.name).toBe("get_time");
587+
expect(parts[1].functionCall.id).toBe("call_test_b");
588+
});
527589
});
528590

529591
// ─── Integration tests: Gemini streaming ────────────────────────────────────
@@ -583,7 +645,7 @@ describe("POST /v1beta/models/{model}:streamGenerateContent (streaming)", () =>
583645
const chunks = parseGeminiSSEChunks(res.body) as {
584646
candidates: {
585647
content: {
586-
parts: { functionCall?: { name: string; args: unknown } }[];
648+
parts: { functionCall?: { name: string; args: unknown; id?: string } }[];
587649
};
588650
finishReason?: string;
589651
}[];
@@ -596,9 +658,37 @@ describe("POST /v1beta/models/{model}:streamGenerateContent (streaming)", () =>
596658
expect(chunks[0].candidates[0].content.parts[0].functionCall!.args).toEqual({
597659
city: "NYC",
598660
});
661+
// Fixture didn't pin an id, so the streamed functionCall should
662+
// omit `id` too — same backward-compat contract as the buffered path.
663+
expect(chunks[0].candidates[0].content.parts[0].functionCall!).not.toHaveProperty("id");
599664
expect(chunks[0].candidates[0].finishReason).toBe("FUNCTION_CALL");
600665
});
601666

667+
it("streams functionCall.id when the fixture pins tc.id", async () => {
668+
// Streaming and buffered tool-call responses both flow through
669+
// `parseToolCallPart`. This test guards against a future regression
670+
// where streaming gets its own serializer that drops the id.
671+
instance = await createServer(allFixtures);
672+
const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:streamGenerateContent`, {
673+
contents: [{ role: "user", parts: [{ text: "pinned-id" }] }],
674+
});
675+
676+
expect(res.status).toBe(200);
677+
678+
const chunks = parseGeminiSSEChunks(res.body) as {
679+
candidates: {
680+
content: {
681+
parts: { functionCall?: { name: string; args: unknown; id?: string } }[];
682+
};
683+
}[];
684+
}[];
685+
686+
expect(chunks).toHaveLength(1);
687+
const fc = chunks[0].candidates[0].content.parts[0].functionCall;
688+
expect(fc).toBeDefined();
689+
expect(fc!.id).toBe("call_test_pinned_001");
690+
});
691+
602692
it("uses fixture chunkSize for text streaming", async () => {
603693
const bigChunkFixture: Fixture = {
604694
match: { userMessage: "bigchunk" },

src/gemini.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,25 @@ function parseToolCallPart(tc: ToolCall, logger: Logger): GeminiPart {
318318
logger.warn(`Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`);
319319
argsObj = {};
320320
}
321-
return { functionCall: { name: tc.name, args: argsObj } };
321+
// Surface the fixture's tool_call.id on the Gemini functionCall response
322+
// so clients can preserve it across the round-trip and any
323+
// toolCallId-keyed follow-up fixtures match. Pairs with v1.23.1's
324+
// INGEST-direction fix (#196) which preserves the id when aimock parses
325+
// an incoming Gemini request — that fix only helps if the id was in the
326+
// response body to begin with. Without this egress fix, aimock emits
327+
// `{ functionCall: { name, args } }` (no id), so even clients that
328+
// diligently preserve `functionCall.id` across the round-trip never see
329+
// an id to preserve. Backward-compatible: fixtures that don't pin a
330+
// tc.id continue to serialize without one (the ingest path's fallback
331+
// generator handles those).
332+
const functionCall: GeminiPart["functionCall"] = {
333+
name: tc.name,
334+
args: argsObj,
335+
};
336+
if (tc.id) {
337+
functionCall.id = tc.id;
338+
}
339+
return { functionCall };
322340
}
323341

324342
function buildGeminiToolCallStreamChunks(

0 commit comments

Comments
 (0)