Skip to content

Commit d6392c8

Browse files
authored
fix(router): toolCallId must only match when tool message is the last turn (#148)
2 parents 5bf98ac + 56955bc commit d6392c8

5 files changed

Lines changed: 71 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# @copilotkit/aimock
22

3+
## [1.16.4] - 2026-04-30
4+
5+
### Fixed
6+
7+
- **Router: `toolCallId` matched stale tool messages from history**. The matcher previously used `getLastMessageByRole(messages, "tool")`, so once a conversation contained any prior tool result, every subsequent request still had a "last tool message" buried in history — a `toolCallId` fixture could win and shadow `userMessage` matchers for new user turns. Tightened to require the tool message to be the **last** message in the request (which is the only state in which the LLM is being asked to respond to a tool result). Surfaced as: in CopilotKit's beautiful-chat showcase, clicking a second suggestion replayed the first chart's content fixture instead of producing a new tool call.
8+
39
## [1.16.3] - 2026-04-29
410

511
### Fixed

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.16.3",
3+
"version": "1.16.4",
44
"description": "Mock infrastructure for AI application testing — LLM APIs, image generation, text-to-speech, transcription, 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__/integration.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,10 @@ describe("integration: tool call flow", () => {
182182

183183
describe("integration: multi-turn flow", () => {
184184
it("handles initial request and tool result follow-up", async () => {
185-
// More specific match (toolCallId) must come first
186-
// since the router returns the first match and
187-
// "change background" is still in the messages array
188-
// on the second turn.
185+
// toolCallId fixtures only fire when the request's last message is a tool
186+
// result, so fixture order between toolCallId and userMessage does not
187+
// matter here — turn 1 (last = user) hits userMessage; turn 2 (last = tool)
188+
// hits toolCallId.
189189
const fixtures: Fixture[] = [
190190
{
191191
match: { toolCallId: "call_bg_001" },

src/__tests__/router.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,60 @@ describe("matchFixture — toolCallId", () => {
277277
const req = makeReq({ messages: [{ role: "user", content: "hello" }] });
278278
expect(matchFixture([fixture], req)).toBeNull();
279279
});
280+
281+
it("does not match when a new user turn follows the tool message", () => {
282+
// Regression: a toolCallId fixture is the response to a tool result, so it
283+
// must only fire when the tool message is the LAST message in the request.
284+
// If the user sends another turn after the tool result, the stale tool_call_id
285+
// in history must not shadow userMessage matchers for the new turn.
286+
const stale = makeFixture(
287+
{ toolCallId: "call_pie_chart" },
288+
{ content: "Pie chart rendered above" },
289+
);
290+
const fresh = makeFixture({ userMessage: "bar chart" }, { content: "bar chart response" });
291+
const req = makeReq({
292+
messages: [
293+
{ role: "user", content: "show me a pie chart" },
294+
{
295+
role: "assistant",
296+
content: null,
297+
tool_calls: [
298+
{
299+
id: "call_pie_chart",
300+
type: "function",
301+
function: { name: "pieChart", arguments: "{}" },
302+
},
303+
],
304+
},
305+
{ role: "tool", content: "{}", tool_call_id: "call_pie_chart" },
306+
{ role: "assistant", content: "Pie chart rendered above" },
307+
{ role: "user", content: "now show me a bar chart" },
308+
],
309+
});
310+
expect(matchFixture([stale, fresh], req)).toBe(fresh);
311+
});
312+
313+
it("does not match when an assistant content message follows the tool message", () => {
314+
// The assistant has already emitted its final content for the tool result;
315+
// any follow-up LLM call that arrives in this state should not re-fire the
316+
// toolCallId fixture (which would loop the same content back).
317+
const stale = makeFixture({ toolCallId: "call_abc" }, { content: "tool answered" });
318+
const req = makeReq({
319+
messages: [
320+
{ role: "user", content: "do thing" },
321+
{
322+
role: "assistant",
323+
content: null,
324+
tool_calls: [
325+
{ id: "call_abc", type: "function", function: { name: "thing", arguments: "{}" } },
326+
],
327+
},
328+
{ role: "tool", content: "{}", tool_call_id: "call_abc" },
329+
{ role: "assistant", content: "tool answered" },
330+
],
331+
});
332+
expect(matchFixture([stale], req)).toBeNull();
333+
});
280334
});
281335

282336
// ---------------------------------------------------------------------------

src/router.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,13 @@ export function matchFixture(
8888
}
8989
}
9090

91-
// toolCallId — match against the last tool message's tool_call_id
91+
// toolCallId — a toolCallId fixture answers the model's response to a tool
92+
// result, which by API contract only happens when the conversation's LAST
93+
// message is a tool result. If a newer user (or other) turn follows the
94+
// tool message, the stale tool_call_id must not shadow userMessage matchers.
9295
if (match.toolCallId !== undefined) {
93-
const msg = getLastMessageByRole(effective.messages, "tool");
94-
if (!msg || msg.tool_call_id !== match.toolCallId) continue;
96+
const last = effective.messages[effective.messages.length - 1];
97+
if (!last || last.role !== "tool" || last.tool_call_id !== match.toolCallId) continue;
9598
}
9699

97100
// toolName — match against any tool definition by function.name

0 commit comments

Comments
 (0)