Skip to content

Commit 7ab35df

Browse files
committed
fix(webapp): merge the turn's cache breakpoint into the last message's Anthropic options
Both places that roll the per-turn cache breakpoint onto the last message wrote `anthropic: { cacheControl }` wholesale, replacing the namespace and silently dropping every other Anthropic option that message carried. `withCacheBreakpointOnLast()` now spreads the existing options and overwrites only `cacheControl`. The agent's `prepareMessages` hook had the same write inlined, so it goes through that one function instead, as `prepareTurnMessages()` — which also gives the hook's behaviour a name a test can reach. The system block's breakpoint in `chat.prompt.set` is left alone: it builds a fresh options object, so there is nothing to merge into.
1 parent c3f4ba8 commit 7ab35df

3 files changed

Lines changed: 101 additions & 22 deletions

File tree

internal-packages/dashboard-agent/src/agent-runtime.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,9 @@ export function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessag
349349
...last,
350350
providerOptions: {
351351
...last.providerOptions,
352-
anthropic: { cacheControl: PROMPT_CACHE_CONTROL },
352+
// Merged, not replaced: the breakpoint is one Anthropic option among any
353+
// others the message already carries.
354+
anthropic: { ...last.providerOptions?.anthropic, cacheControl: PROMPT_CACHE_CONTROL },
353355
},
354356
},
355357
];
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// `@trigger.dev/sdk/ai/test` MUST be imported before the agent module so the
2+
// resource catalog is installed before `chat.agent({ id })` / `prompts.define`
3+
// register at module load.
4+
import "@trigger.dev/sdk/ai/test";
5+
6+
import type { ModelMessage } from "ai";
7+
import { describe, expect, it } from "vitest";
8+
9+
import { withCacheBreakpointOnLast } from "./agent-runtime";
10+
import { prepareTurnMessages } from "./dashboard-agent";
11+
import { PROMPT_CACHE_CONTROL } from "./prompt-prefix";
12+
13+
/**
14+
* Rolling the turn's cache breakpoint onto the last message must not take the rest of
15+
* that message's Anthropic options with it: writing the namespace wholesale silently
16+
* dropped every other option the message carried.
17+
*/
18+
19+
function lastMessageWithAnthropicOptions(): ModelMessage[] {
20+
return [
21+
{ role: "user", content: "first" },
22+
{
23+
role: "user",
24+
content: "second",
25+
providerOptions: {
26+
anthropic: { cacheControl: { type: "ephemeral", ttl: "5m" }, thinking: { budget: 1024 } },
27+
openai: { store: false },
28+
},
29+
},
30+
];
31+
}
32+
33+
describe("withCacheBreakpointOnLast", () => {
34+
it("keeps the last message's other Anthropic options alongside the breakpoint", () => {
35+
const prepared = withCacheBreakpointOnLast(lastMessageWithAnthropicOptions());
36+
37+
expect(prepared[1]!.providerOptions).toEqual({
38+
anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } },
39+
openai: { store: false },
40+
});
41+
});
42+
43+
it("leaves earlier messages untouched", () => {
44+
const messages = lastMessageWithAnthropicOptions();
45+
expect(withCacheBreakpointOnLast(messages)[0]).toEqual(messages[0]);
46+
});
47+
});
48+
49+
describe("prepareTurnMessages", () => {
50+
it("keeps the last message's other Anthropic options alongside the breakpoint", () => {
51+
const prepared = prepareTurnMessages({
52+
messages: lastMessageWithAnthropicOptions(),
53+
reason: "run",
54+
});
55+
56+
expect(prepared[1]!.providerOptions).toEqual({
57+
anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } },
58+
openai: { store: false },
59+
});
60+
});
61+
62+
it("still coerces a replayed tool input the API would reject", () => {
63+
const prepared = prepareTurnMessages({
64+
messages: [
65+
{
66+
role: "assistant",
67+
content: [{ type: "tool-call", toolCallId: "call_1", toolName: "noop", input: "" }],
68+
},
69+
],
70+
reason: "run",
71+
});
72+
73+
expect((prepared[0]!.content as { input: unknown }[])[0]!.input).toEqual({});
74+
});
75+
});

internal-packages/dashboard-agent/src/dashboard-agent.ts

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
settlementCardMessages,
2121
clearOpenInvestigations,
2222
pendingInvestigationSettlements,
23+
withCacheBreakpointOnLast,
2324
type DashboardAgentStore,
2425
} from "./agent-runtime";
2526
import { titlePrompt } from "./prompts";
@@ -314,6 +315,27 @@ export type {
314315
AgentPageSignal,
315316
} from "@internal/dashboard-agent-contracts";
316317

318+
/**
319+
* What the turn's model actually sees: replayed tool inputs the API would reject
320+
* coerced back, the durable state pinned back on, and a cache breakpoint on the last
321+
* message so the growing conversation prefix is read back cheaply.
322+
*
323+
* The between-steps compaction path rebuilds history as the summary alone and never
324+
* reaches `compactModelMessages`, so the live investigation and watch state is pinned
325+
* back here instead.
326+
*/
327+
export function prepareTurnMessages(args: {
328+
messages: ModelMessage[];
329+
reason: string;
330+
}): ModelMessage[] {
331+
if (args.messages.length === 0) return args.messages;
332+
return withCacheBreakpointOnLast(
333+
sanitizeReplayedToolInputs(
334+
args.reason === "run" ? args.messages : withDurableState(args.messages, chat.history.all())
335+
)
336+
);
337+
}
338+
317339
export const dashboardAgent = chat.agent({
318340
id: "dashboard-agent",
319341
clientDataSchema,
@@ -525,27 +547,7 @@ export const dashboardAgent = chat.agent({
525547
// conversation prefix is cached and read back cheaply. Composes with the
526548
// system-block breakpoint above. chat.agent keeps the Head Start handover's
527549
// tool-approval tail intact across this hook, so it is safe on a resume turn.
528-
prepareMessages: ({ messages, reason }) => {
529-
if (messages.length === 0) return messages;
530-
// The between-steps compaction path rebuilds history as the summary alone and
531-
// never reaches `compactModelMessages`, so the live investigation and watch state
532-
// is pinned back here instead.
533-
const sanitized = sanitizeReplayedToolInputs(
534-
reason === "run" ? messages : withDurableState(messages, chat.history.all())
535-
);
536-
537-
const last = sanitized[sanitized.length - 1];
538-
return [
539-
...sanitized.slice(0, -1),
540-
{
541-
...last,
542-
providerOptions: {
543-
...last.providerOptions,
544-
anthropic: { cacheControl: PROMPT_CACHE_CONTROL },
545-
},
546-
},
547-
];
548-
},
550+
prepareMessages: ({ messages, reason }) => prepareTurnMessages({ messages, reason }),
549551

550552
// System prompt and model come from the managed prompt set in onTurnStart, so
551553
// they are dashboard-editable. toStreamTextOptions() supplies the system text

0 commit comments

Comments
 (0)