From d3037d923b3b9a25b42c40257f5b68d0a01cdf9d Mon Sep 17 00:00:00 2001 From: ran Date: Mon, 20 Jul 2026 13:24:19 +0200 Subject: [PATCH] fix(router): scope hasToolResult to the current turn (fixes multi-turn replay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `match.hasToolResult` was a whole-conversation predicate: "does ANY message have role tool". In a multi-turn chat session that pins it to `true` forever after the first tool call, so a turn's leg-1 fixture (hasToolResult:false, the tool-call leg) can never match again — the 2nd+ user turn always carries an earlier turn's tool result. Result: every pill/tool after the first in a shared thread returns "No fixture matched" (reproduced in the CopilotKit showcase Beautiful Chat demo: pill 1 works, pill 2+ dead). Scope it to the CURRENT turn — messages AFTER the last user message. For a single-turn request this is identical to the old check, so existing fixtures are unaffected; multi-turn leg-1 fixtures now match again. Updated the unit tests to use realistic leg-2 shapes (tool result is the last message of the turn, not followed by a fresh user message) and added a multi-turn regression test. --- src/__tests__/router.test.ts | 50 +++++++++++++++++++++++++++++++----- src/router.ts | 26 +++++++++++++++---- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/__tests__/router.test.ts b/src/__tests__/router.test.ts index a8190b09..e76c66e3 100644 --- a/src/__tests__/router.test.ts +++ b/src/__tests__/router.test.ts @@ -1222,14 +1222,22 @@ describe("matchFixture — turnIndex", () => { // --------------------------------------------------------------------------- describe("matchFixture — hasToolResult", () => { - it("matches hasToolResult: true when tool messages present", () => { + // hasToolResult is TURN-SCOPED: it asks whether the CURRENT turn (messages + // after the last user message) contains a tool result — not whether the whole + // conversation ever did. This is what lets the leg-1 (tool call) / leg-2 + // (post-tool narration) fixture pair keep discriminating across MULTI-TURN + // sessions: on the 2nd+ user turn the request still carries earlier turns' + // tool results, and a whole-conversation check would pin hasToolResult=true + // forever so the turn's leg-1 fixture (false) could never match again. + + it("matches hasToolResult: true when the current turn has a tool result", () => { const fixture = makeFixture({ userMessage: "hello", hasToolResult: true }); + // Real leg-2 shape: the tool result is the LAST message (no new user turn). const req = makeReq({ messages: [ { role: "user", content: "hello" }, { role: "assistant", content: "calling tool" }, { role: "tool", content: "tool output" }, - { role: "user", content: "hello" }, ], }); expect(matchFixture([fixture], req)).toBe(fixture); @@ -1255,20 +1263,36 @@ describe("matchFixture — hasToolResult", () => { expect(matchFixture([fixture], req)).toBe(fixture); }); - it("skips hasToolResult: false when tool messages present", () => { + it("skips hasToolResult: false when the current turn has a tool result", () => { const fixture = makeFixture({ userMessage: "hello", hasToolResult: false }); + // Real leg-2 shape: tool result is the last message in the current turn. const req = makeReq({ messages: [ { role: "user", content: "hello" }, { role: "assistant", content: "calling tool" }, { role: "tool", content: "tool output" }, - { role: "user", content: "hello" }, ], }); expect(matchFixture([fixture], req)).toBeNull(); }); - it("discriminates 2-step HITL flow with hasToolResult", () => { + it("matches hasToolResult: false on a new turn even when an EARLIER turn had a tool result (multi-turn)", () => { + // The regression this scoping fixes: a 2nd pill click in the same chat + // thread. The request carries turn-1's tool result, but the CURRENT turn + // (after the last user message) has none, so leg-1 (false) must still match. + const fixture = makeFixture({ userMessage: "hello", hasToolResult: false }); + const req = makeReq({ + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "calling tool" }, + { role: "tool", content: "prior-turn tool output" }, + { role: "user", content: "hello" }, + ], + }); + expect(matchFixture([fixture], req)).toBe(fixture); + }); + + it("discriminates 2-step HITL flow with hasToolResult across turns", () => { const beforeTool = makeFixture( { userMessage: "hello", hasToolResult: false }, { content: "before-tool" }, @@ -1278,20 +1302,34 @@ describe("matchFixture — hasToolResult", () => { { content: "after-tool" }, ); + // Turn 1, leg 1: fresh user message, no tool yet. const reqBefore = makeReq({ messages: [{ role: "user", content: "hello" }], }); expect(matchFixture([beforeTool, afterTool], reqBefore)).toBe(beforeTool); + // Turn 1, leg 2: the tool result is the last message in the current turn. const reqAfter = makeReq({ messages: [ { role: "user", content: "hello" }, { role: "assistant", content: "calling tool" }, { role: "tool", content: "result" }, - { role: "user", content: "hello" }, ], }); expect(matchFixture([beforeTool, afterTool], reqAfter)).toBe(afterTool); + + // Turn 2, leg 1: a new user message after turn-1's completed tool flow — + // must fall back to the leg-1 fixture, not re-serve leg-2's narration. + const reqNextTurn = makeReq({ + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "calling tool" }, + { role: "tool", content: "result" }, + { role: "assistant", content: "before-tool" }, + { role: "user", content: "hello" }, + ], + }); + expect(matchFixture([beforeTool, afterTool], reqNextTurn)).toBe(beforeTool); }); }); diff --git a/src/router.ts b/src/router.ts index 66b71a07..984a99ac 100644 --- a/src/router.ts +++ b/src/router.ts @@ -461,12 +461,28 @@ export function matchFixtureDiagnostic( } } - // hasToolResult — request-SHAPE predicate: does the conversation contain a - // tool result message? Must be evaluated with the other shape predicates - // ABOVE the sequence/turn state gates so that a fixture whose shape never - // matched is not miscounted as "skipped by sequence/turn state". + // hasToolResult — request-SHAPE predicate: does the CURRENT turn contain a + // tool result message? "Current turn" = messages after the last user + // message, so this is scoped to the turn being matched rather than the + // whole conversation. This is what makes leg-1 (tool call, hasToolResult + // false) vs leg-2 (narration, hasToolResult true) fixtures keep working + // across MULTI-TURN sessions: on the 2nd+ user turn the request still + // carries earlier turns' tool results, and a whole-conversation check would + // force hasToolResult=true forever, so the turn's leg-1 fixture (false) + // could never match again ("No fixture matched" on every pill after the + // first). For a single-turn request this is identical to the old + // whole-conversation check. Must be evaluated with the other shape + // predicates ABOVE the sequence/turn state gates so that a fixture whose + // shape never matched is not miscounted as "skipped by sequence/turn state". if (match.hasToolResult !== undefined) { - const hasTool = effective.messages.some((m) => m.role === "tool"); + let lastUserIdx = -1; + for (let i = effective.messages.length - 1; i >= 0; i--) { + if (effective.messages[i].role === "user") { + lastUserIdx = i; + break; + } + } + const hasTool = effective.messages.slice(lastUserIdx + 1).some((m) => m.role === "tool"); if (hasTool !== match.hasToolResult) continue; }