Files
HealthLog/src/lib/ai/coach/__tests__/tools-loop.test.ts
T
Marc-André Bombeck abceff04c7 Build the Coach snapshot once per turn and widen the round budget to three
Each retrieval tool re-scoped the snapshot to a single source, so its cache key
never matched the inventory's full-source build — a four-tool turn paid four
extra full snapshot builds on top of the inventory's. Thread the inventory's
probe scope to every tool so a per-tool read lands the one cache entry the
inventory already primed, collapsing the turn to a single build (a window
override still gets its own correct build). Raise the loop's round cap from two
to three so a sequential cross-metric why-chain isn't starved; the per-round
token budget is unchanged and the absolute ceiling tracks one above so the
final round still forces prose.
2026-06-26 13:32:42 +02:00

278 lines
9.2 KiB
TypeScript

/**
* v1.20.0 (F1) — bounded Coach retrieval loop: tool dispatch round-trip, the
* max-rounds cap with a forced-final answer, parallel tool execution, and the
* summed-token accounting the budget reconcile depends on.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
vi.mock("@/lib/ai/coach/tools/executor", () => ({
executeCoachTool: (args: unknown) => executeCoachTool(args),
}));
// The loop calls the real fallback runner; stub it to a deterministic
// per-round script so we exercise the loop control flow, not the chain.
const runRawCompletionWithFallback = vi.fn();
vi.mock("@/lib/ai/provider-runner", () => ({
runRawCompletionWithFallback: (args: unknown) =>
runRawCompletionWithFallback(args),
}));
import {
runCoachToolLoop,
HARD_CAP,
MAX_ROUNDS,
} from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
function completion(opts: {
content: string;
tokensUsed?: number;
toolCalls?: Array<{ id: string; name: string; arguments: string }>;
finishReason?: "stop" | "tool_calls" | "length";
}) {
return {
result: {
content: opts.content,
tokensUsed: opts.tokensUsed ?? 10,
model: "mock",
providerType: "anthropic" as const,
...(opts.toolCalls ? { toolCalls: opts.toolCalls } : {}),
finishReason: opts.finishReason,
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
};
}
const baseArgs = {
userId: "u1",
providers: [],
system: "sys",
messages: [{ role: "user" as const, content: "how is my bp?" }],
tools: COACH_TOOL_DEFS,
};
describe("runCoachToolLoop", () => {
beforeEach(() => {
executeCoachTool.mockReset();
runRawCompletionWithFallback.mockReset();
});
it("runs one tool round then streams the final prose (happy path)", async () => {
executeCoachTool.mockResolvedValue({ present: true, data: { x: 1 } });
runRawCompletionWithFallback
.mockResolvedValueOnce(
completion({
content: "",
tokensUsed: 30,
finishReason: "tool_calls",
toolCalls: [
{
id: "c1",
name: "get_metric_series",
arguments: '{"metric":"bp"}',
},
],
}),
)
.mockResolvedValueOnce(
completion({ content: "Your BP looks steady.", tokensUsed: 20 }),
);
const out = await runCoachToolLoop(baseArgs);
expect(out.rounds).toBe(2);
expect(out.result.content).toBe("Your BP looks steady.");
expect(out.totalTokens).toBe(50); // summed across both rounds
expect(out.toolTrace).toEqual([
{ name: "get_metric_series", present: true },
]);
// v1.21.0 (P6) — the present result's payload is retained for the prose
// number-verifier.
expect(out.toolResults).toEqual([{ present: true, data: { x: 1 } }]);
// The second call must forbid tools? No — round 2 still offers them
// (round <= MAX_ROUNDS). It simply chose to answer.
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(2);
});
it("answers immediately when the model emits no tool calls", async () => {
runRawCompletionWithFallback.mockResolvedValueOnce(
completion({ content: "Here's what I can help with.", tokensUsed: 12 }),
);
const out = await runCoachToolLoop(baseArgs);
expect(out.rounds).toBe(1);
expect(out.toolTrace).toHaveLength(0);
expect(executeCoachTool).not.toHaveBeenCalled();
});
it("executes parallel tool calls in one round", async () => {
executeCoachTool.mockResolvedValue({ present: true });
runRawCompletionWithFallback
.mockResolvedValueOnce(
completion({
content: "",
finishReason: "tool_calls",
toolCalls: [
{
id: "a",
name: "get_metric_series",
arguments: '{"metric":"bp"}',
},
{ id: "b", name: "get_sleep", arguments: "{}" },
],
}),
)
.mockResolvedValueOnce(completion({ content: "Combined view." }));
const out = await runCoachToolLoop(baseArgs);
expect(executeCoachTool).toHaveBeenCalledTimes(2);
expect(out.toolTrace).toHaveLength(2);
expect(out.result.content).toBe("Combined view.");
});
it("forces a final answer at the hard cap (no infinite loop)", async () => {
executeCoachTool.mockResolvedValue({ present: false });
// The model keeps asking for tools every round. The loop must still
// terminate: the last allowed round offers no tools (toolChoice none) so
// the model is forced to produce prose.
runRawCompletionWithFallback.mockImplementation(
(args: { params: { toolChoice?: string } }) => {
if (args.params.toolChoice === "none") {
return Promise.resolve(
completion({ content: "Final forced answer." }),
);
}
return Promise.resolve(
completion({
content: "",
finishReason: "tool_calls",
toolCalls: [
{
id: "x",
name: "get_metric_series",
arguments: '{"metric":"bp"}',
},
],
}),
);
},
);
const out = await runCoachToolLoop(baseArgs);
expect(out.rounds).toBe(HARD_CAP);
expect(out.result.content).toBe("Final forced answer.");
// The final round must have been called with toolChoice "none".
const lastCall =
runRawCompletionWithFallback.mock.calls[
runRawCompletionWithFallback.mock.calls.length - 1
][0];
expect(lastCall.params.toolChoice).toBe("none");
});
it("appends assistant(toolCalls) + tool turns to the message array", async () => {
executeCoachTool.mockResolvedValue({ present: true, data: { v: 1 } });
let secondCallMessages: unknown;
runRawCompletionWithFallback
.mockResolvedValueOnce(
completion({
content: "",
finishReason: "tool_calls",
toolCalls: [{ id: "c1", name: "get_sleep", arguments: "{}" }],
}),
)
.mockImplementationOnce((args: { params: { messages: unknown } }) => {
secondCallMessages = args.params.messages;
return Promise.resolve(completion({ content: "done" }));
});
await runCoachToolLoop(baseArgs);
const msgs = secondCallMessages as Array<{
role: string;
toolCallId?: string;
toolCalls?: unknown[];
}>;
// user, assistant(toolCalls), tool
expect(msgs).toHaveLength(3);
expect(msgs[1].role).toBe("assistant");
expect(msgs[1].toolCalls).toHaveLength(1);
expect(msgs[2].role).toBe("tool");
expect(msgs[2].toolCallId).toBe("c1");
});
});
// v1.21.0 (D5) — the round cap is 3 (sequential cross-metric "why" chains are
// no longer starved on a 2-round ceiling), and the absolute ceiling tracks one
// above so the final allowed round still forces prose.
describe("round bounds (D5)", () => {
it("MAX_ROUNDS is 3 and HARD_CAP tracks one above", () => {
expect(MAX_ROUNDS).toBe(3);
expect(HARD_CAP).toBe(MAX_ROUNDS + 1);
});
it("allows up to three tool-fetch rounds before forcing prose", async () => {
executeCoachTool.mockResolvedValue({ present: false });
let toolRounds = 0;
runRawCompletionWithFallback.mockImplementation(
(args: { params: { toolChoice?: string } }) => {
if (args.params.toolChoice === "none") {
return Promise.resolve(completion({ content: "Forced final." }));
}
toolRounds += 1;
return Promise.resolve(
completion({
content: "",
finishReason: "tool_calls",
toolCalls: [
{
id: "c",
name: "get_metric_series",
arguments: '{"metric":"bp"}',
},
],
}),
);
},
);
const out = await runCoachToolLoop(baseArgs);
// Three rounds offered tools, the fourth forced prose.
expect(toolRounds).toBe(MAX_ROUNDS);
expect(out.rounds).toBe(HARD_CAP);
expect(out.result.content).toBe("Forced final.");
});
});
// v1.21.0 (D5-1) — the loop threads the turn's shared snapshot scope to every
// tool so each per-tool read lands one cache key (one snapshot build per turn).
describe("shared snapshot scope threading (D5-1)", () => {
it("passes sharedScope through to executeCoachTool", async () => {
executeCoachTool.mockResolvedValue({ present: true, data: { x: 1 } });
const shared = {
sources: ["bp", "hrv"] as never,
window: "last30days" as const,
};
runRawCompletionWithFallback
.mockResolvedValueOnce(
completion({
content: "",
finishReason: "tool_calls",
toolCalls: [
{
id: "c1",
name: "get_metric_series",
arguments: '{"metric":"bp"}',
},
],
}),
)
.mockResolvedValueOnce(completion({ content: "done" }));
await runCoachToolLoop({ ...baseArgs, sharedScope: shared });
expect(executeCoachTool).toHaveBeenCalledWith(
expect.objectContaining({ sharedScope: shared }),
);
});
});