Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @copilotkit/aimock

## [1.16.4] - 2026-04-30

### Fixed

- **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.

## [1.16.3] - 2026-04-29

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@copilotkit/aimock",
"version": "1.16.3",
"version": "1.16.4",
"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.",
"license": "MIT",
"keywords": [
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,10 @@ describe("integration: tool call flow", () => {

describe("integration: multi-turn flow", () => {
it("handles initial request and tool result follow-up", async () => {
// More specific match (toolCallId) must come first
// since the router returns the first match and
// "change background" is still in the messages array
// on the second turn.
// toolCallId fixtures only fire when the request's last message is a tool
// result, so fixture order between toolCallId and userMessage does not
// matter here — turn 1 (last = user) hits userMessage; turn 2 (last = tool)
// hits toolCallId.
const fixtures: Fixture[] = [
{
match: { toolCallId: "call_bg_001" },
Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,60 @@ describe("matchFixture — toolCallId", () => {
const req = makeReq({ messages: [{ role: "user", content: "hello" }] });
expect(matchFixture([fixture], req)).toBeNull();
});

it("does not match when a new user turn follows the tool message", () => {
// Regression: a toolCallId fixture is the response to a tool result, so it
// must only fire when the tool message is the LAST message in the request.
// If the user sends another turn after the tool result, the stale tool_call_id
// in history must not shadow userMessage matchers for the new turn.
const stale = makeFixture(
{ toolCallId: "call_pie_chart" },
{ content: "Pie chart rendered above" },
);
const fresh = makeFixture({ userMessage: "bar chart" }, { content: "bar chart response" });
const req = makeReq({
messages: [
{ role: "user", content: "show me a pie chart" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_pie_chart",
type: "function",
function: { name: "pieChart", arguments: "{}" },
},
],
},
{ role: "tool", content: "{}", tool_call_id: "call_pie_chart" },
{ role: "assistant", content: "Pie chart rendered above" },
{ role: "user", content: "now show me a bar chart" },
],
});
expect(matchFixture([stale, fresh], req)).toBe(fresh);
});

it("does not match when an assistant content message follows the tool message", () => {
// The assistant has already emitted its final content for the tool result;
// any follow-up LLM call that arrives in this state should not re-fire the
// toolCallId fixture (which would loop the same content back).
const stale = makeFixture({ toolCallId: "call_abc" }, { content: "tool answered" });
const req = makeReq({
messages: [
{ role: "user", content: "do thing" },
{
role: "assistant",
content: null,
tool_calls: [
{ id: "call_abc", type: "function", function: { name: "thing", arguments: "{}" } },
],
},
{ role: "tool", content: "{}", tool_call_id: "call_abc" },
{ role: "assistant", content: "tool answered" },
],
});
expect(matchFixture([stale], req)).toBeNull();
});
});

// ---------------------------------------------------------------------------
Expand Down
9 changes: 6 additions & 3 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,13 @@ export function matchFixture(
}
}

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

// toolName — match against any tool definition by function.name
Expand Down
Loading