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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# @copilotkit/aimock

## [1.37.4] - 2026-07-20

### Fixed

- `hasToolResult` record and replay are symmetric again: both the recorder (`buildFixtureMatch`) and the matcher (`matchFixtureDiagnostic`) now derive the value from one shared `currentTurnHasToolResult` helper. The recorder previously STAMPED the value with a whole-conversation `messages.some((m) => m.role === "tool")` while the matcher (as of 1.37.3) CHECKS it scoped to the current turn, so a genuinely-recorded multi-turn fixture (a fresh user turn whose history still carried an earlier turn's tool result) was stamped `true` but matched `false` and could never replay its own recorded request. Sharing one predicate makes the two sides structurally unable to drift (#319)
- Anthropic `tool_result` blocks bundled WITH accompanying text in a single user message now normalize with the text message BEFORE the tool message (`[..., user(text), tool]`), so a leg-2-with-text turn is correctly classified as carrying a tool result. Previously the tool message was emitted first (`[..., tool, user(text)]`), which is byte-identical to a fresh leg-1 turn and was misclassified `false`. The common `tool_result`-only follow-up (no text) is unaffected (#319)

## [1.37.3] - 2026-07-20

### Changed

- **Behavior change:** the `hasToolResult` match predicate is now scoped to the CURRENT turn (messages after the last `role: "user"` message) instead of the whole conversation. This lets a leg-1 (`false`) / leg-2 (`true`) fixture pair keep discriminating across multi-turn sessions, where the request on the 2nd+ user turn still carries earlier turns' tool results and a whole-conversation check would pin `hasToolResult` to `true` forever. Migration: any fixture that relied on `hasToolResult: true` meaning "a tool result appears anywhere in the conversation" (e.g. a multi-turn fixture that previously stuck on `true` once the first tool ran) will now only match on the turn that actually carries the tool result; a request with no `role: "user"` message still falls back to the whole-conversation scan (#316)

## [1.37.2] - 2026-07-17

### Fixed
Expand Down
56 changes: 28 additions & 28 deletions skills/write-fixtures/SKILL.md

Large diffs are not rendered by default.

197 changes: 197 additions & 0 deletions src/__tests__/hastoolresult-record-replay-symmetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { describe, it, expect, afterEach } from "vitest";
import * as http from "node:http";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { createServer, type ServerInstance } from "../server.js";
import { loadFixturesFromDir } from "../fixture-loader.js";

// ---------------------------------------------------------------------------
// HTTP helper
// ---------------------------------------------------------------------------

function post(
url: string,
body: unknown,
headers?: Record<string, string>,
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const parsed = new URL(url);
const req = http.request(
{
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(data),
...headers,
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () =>
resolve({
status: res.statusCode ?? 0,
headers: res.headers,
body: Buffer.concat(chunks).toString(),
}),
);
},
);
req.on("error", reject);
req.write(data);
req.end();
});
}

// ---------------------------------------------------------------------------
// Server lifecycle
// ---------------------------------------------------------------------------

let upstream: ServerInstance | undefined;
let recorder: ServerInstance | undefined;
let replay: ServerInstance | undefined;
let tmpDir: string | undefined;

afterEach(async () => {
for (const s of [replay, recorder, upstream]) {
if (s) await new Promise<void>((resolve) => s.server.close(() => resolve()));
}
replay = recorder = upstream = undefined;
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
}
});

// ---------------------------------------------------------------------------
// Finding 1 — recorder/matcher record→replay symmetry (OpenAI shape)
// ---------------------------------------------------------------------------

describe("hasToolResult record→replay symmetry", () => {
it("replays a genuinely-recorded turn-2 leg-1 request against its own recorded fixture", async () => {
// Turn-2 leg-1: a FRESH user question whose history still carries turn-1's
// completed tool result. Shape: [user, assistant(tool_call), tool, user].
// Current-turn hasToolResult is FALSE (nothing after the last user message),
// but the whole conversation DOES contain a tool message. Pre-fix the
// recorder stamped `true` (whole-conversation) while the matcher checked
// `false` (turn-scoped) → the fixture could never match its own request.
const turn2Leg1 = {
model: "gpt-4",
messages: [
{ role: "user", content: "first question" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "lookup", arguments: "{}" },
},
],
},
{ role: "tool", content: "tool output", tool_call_id: "call_1" },
{ role: "user", content: "second question" },
],
};

// 1. Upstream mock returns an answer for the second question.
upstream = await createServer(
[
{
match: { userMessage: "second question" },
response: { content: "Answer to the second question." },
},
],
{ port: 0, logLevel: "silent" },
);

// 2. Recorder proxies the turn-2 leg-1 request to upstream and records it.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-sym-"));
recorder = await createServer([], {
port: 0,
logLevel: "silent",
record: { providers: { openai: upstream.url }, fixturePath: tmpDir },
});
const recResp = await post(`${recorder.url}/v1/chat/completions`, turn2Leg1, {
"x-test-id": "symmetry",
});
expect(recResp.status).toBe(200);

// 3. Replay the SAME request against the just-recorded fixtures.
const fixtures = loadFixturesFromDir(tmpDir);
expect(fixtures.length).toBeGreaterThan(0);
replay = await createServer(fixtures, { port: 0, logLevel: "silent", strict: true });
const replayResp = await post(`${replay.url}/v1/chat/completions`, turn2Leg1);

// Pre-fix: 503 "No fixture matched" (recorded true vs matched false).
// Post-fix: matches and serves the recorded answer.
expect(replayResp.status).toBe(200);
expect(replayResp.body).toContain("Answer to the second question.");
});

it("replays an Anthropic leg-2 turn that bundles tool_result WITH accompanying text", async () => {
// Anthropic packs a leg-2 tool_result and accompanying user text into ONE
// user message. Normalization must emit `[..., user(text), tool]` so the tool
// trails the last user message and the turn is classified as carrying a tool
// result (`true`). Pre-fix it emitted `[..., tool, user(text)]`, byte-identical
// to a fresh leg-1 turn, so the recorded fixture (whole-conversation `true`)
// could never match the turn-scoped `false` the matcher computed on replay.
const leg2WithText = {
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
messages: [
{ role: "user", content: "initial question" },
{
role: "assistant",
content: [{ type: "tool_use", id: "toolu_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "toolu_1", content: "tool data" },
{ type: "text", text: "please summarize the tool result" },
],
},
],
};

// Upstream matches on the accompanying text (the last user message).
upstream = await createServer(
[
{
match: { userMessage: "please summarize the tool result" },
response: { content: "Recorded narration answer." },
},
],
{ port: 0, logLevel: "silent" },
);

tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-anthropic-"));
recorder = await createServer([], {
port: 0,
logLevel: "silent",
record: { providers: { anthropic: upstream.url }, fixturePath: tmpDir },
});
const recResp = await post(`${recorder.url}/v1/messages`, leg2WithText, {
"x-test-id": "anthropic-leg2-text",
});
expect(recResp.status).toBe(200);

const fixtures = loadFixturesFromDir(tmpDir);
expect(fixtures.length).toBeGreaterThan(0);
// The recorded fixture must carry the turn-scoped classification (true).
expect(fixtures.some((f) => f.match.hasToolResult === true)).toBe(true);

replay = await createServer(fixtures, { port: 0, logLevel: "silent", strict: true });
const replayResp = await post(`${replay.url}/v1/messages`, leg2WithText);

expect(replayResp.status).toBe(200);
expect(replayResp.body).toContain("Recorded narration answer.");
});
});
22 changes: 15 additions & 7 deletions src/__tests__/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,12 +854,16 @@ describe("claudeToCompletionRequest (fallback branches)", () => {
},
],
});
// Should produce tool message + user message
// Should produce user message (accompanying text) THEN tool message. The
// text is emitted FIRST so the tool message trails the last user message and
// the current-turn hasToolResult predicate classifies this leg-2 turn as
// carrying a tool result (`true`). Emitting the tool first would make this
// byte-identical to a genuine leg-1 turn and misclassify it `false`.
expect(result.messages).toHaveLength(2);
expect(result.messages[0].role).toBe("tool");
expect(result.messages[0].content).toBe("result data");
expect(result.messages[1].role).toBe("user");
expect(result.messages[1].content).toBe("follow up question");
expect(result.messages[0].role).toBe("user");
expect(result.messages[0].content).toBe("follow up question");
expect(result.messages[1].role).toBe("tool");
expect(result.messages[1].content).toBe("result data");
});

it("handles text content blocks with missing text (text ?? '')", () => {
Expand Down Expand Up @@ -1086,8 +1090,12 @@ describe("claudeToCompletionRequest (fallback branches)", () => {
},
],
});
expect(result.messages[1].role).toBe("user");
expect(result.messages[1].content).toBe("");
// Accompanying text is emitted BEFORE the tool message (see the ordering
// rationale in claudeToCompletionRequest); a missing text field → "".
expect(result.messages[0].role).toBe("user");
expect(result.messages[0].content).toBe("");
expect(result.messages[1].role).toBe("tool");
expect(result.messages[1].content).toBe("result");
});

it("handles system content blocks with text ?? '' in filter/map", () => {
Expand Down
Loading
Loading