Skip to content

Commit 7917d27

Browse files
fix(web): stop dashboard grounded-copy AI truncating at its output cap
Co-authored-by: polylane[bot] <277585245+polylane[bot]@users.noreply.github.com>
1 parent 8912397 commit 7917d27

2 files changed

Lines changed: 79 additions & 1 deletion

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { z } from "zod";
3+
4+
const { mockGetAIModel, mockGenerateText } = vi.hoisted(() => ({
5+
mockGetAIModel: vi.fn(),
6+
mockGenerateText: vi.fn(),
7+
}));
8+
9+
vi.mock("@wraps/ai", async (importOriginal) => {
10+
const actual = await importOriginal<typeof import("@wraps/ai")>();
11+
// Partial mock: keep DEPLOYMENT_MODE_ENV and the rest that `aiEnv()` reads.
12+
return { ...actual, getAIModel: mockGetAIModel };
13+
});
14+
15+
vi.mock("ai", async (importOriginal) => {
16+
const actual = await importOriginal<typeof import("ai")>();
17+
return { ...actual, generateText: mockGenerateText };
18+
});
19+
20+
const { generateGroundedCopy } = await import("../grounded-copy");
21+
22+
const schema = z.object({ title: z.string(), description: z.string() });
23+
const fallback = { title: "Static title", description: "Static description" };
24+
25+
const generated = {
26+
title: "Bounce rate at 6.0%",
27+
description: "This is above the 5% threshold.",
28+
};
29+
30+
describe("generateGroundedCopy model request", () => {
31+
beforeEach(() => {
32+
mockGetAIModel.mockReset();
33+
mockGenerateText.mockReset();
34+
mockGetAIModel.mockResolvedValue({
35+
model: { modelId: "claude-haiku-5.5" },
36+
modelId: "claude-haiku-5.5",
37+
providerOptions: undefined,
38+
});
39+
mockGenerateText.mockResolvedValue({
40+
output: generated,
41+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
42+
});
43+
});
44+
45+
it("requests the model without reasoning so thinking cannot consume the output cap", async () => {
46+
await generateGroundedCopy({
47+
schema,
48+
system: "You write short dashboard copy.",
49+
facts: { current: 6 },
50+
fallback,
51+
});
52+
53+
expect(mockGetAIModel).toHaveBeenCalledTimes(1);
54+
const [request] = mockGetAIModel.mock.calls[0];
55+
// `maxOutputTokens` is 300; reasoning tokens count against it and
56+
// truncated the JSON, so the request must not enable reasoning.
57+
expect(request).toEqual({ model: "claude-haiku-5.5" });
58+
expect(request).not.toHaveProperty("reasoning");
59+
});
60+
61+
it("returns the parsed copy when the model answers within the cap", async () => {
62+
const result = await generateGroundedCopy({
63+
schema,
64+
system: "You write short dashboard copy.",
65+
facts: { current: 6 },
66+
fallback,
67+
});
68+
69+
expect(result).toEqual({ value: generated, generated: true });
70+
});
71+
});

‎apps/web/src/lib/ai/grounded-copy.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,15 @@ export async function generateGroundedCopy<T>(args: {
3636
try {
3737
// A two-line rephrase of established facts: the smallest model is enough,
3838
// and providers that cannot serve it fall back to their default.
39+
//
40+
// No `reasoning` here. Thinking tokens count against `maxOutputTokens`,
41+
// which is tuned for the two-line answer below, so enabling thinking made
42+
// the model spend the budget before the JSON was complete. Every response
43+
// came back truncated (finish_reason "length"), structured-output parsing
44+
// threw, and every call fell back to the static copy. The copy routes that
45+
// do want reasoning pair it with a far larger cap.
3946
const { model, modelId, providerOptions } = await getAIModel(
40-
{ model: "claude-haiku-5.5", reasoning: { effort: "low" } },
47+
{ model: "claude-haiku-5.5" },
4148
aiEnv()
4249
);
4350

0 commit comments

Comments
 (0)