Skip to content

Commit b126116

Browse files
committed
fix(dashboard-agent): detect legacy cache breakpoints without the discriminator
Conversations persisted before __cacheBreakpoint existed carry a bare anthropic.cacheControl. Classify it by ttl and strip it on resume so legacy breakpoints don't slip past the 4-breakpoint limit.
1 parent b6a73d5 commit b126116

3 files changed

Lines changed: 52 additions & 2 deletions

File tree

internal-packages/dashboard-agent/src/model-provider.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,25 @@ describe("cache breakpoints", () => {
9696
expect(isLongLivedCacheBreakpoint(bedrockPrefix)).toBe(true);
9797
expect(withoutCacheBreakpoint(bedrockStep)).toEqual({});
9898
});
99+
100+
// Conversations persisted before the __cacheBreakpoint discriminator existed carry
101+
// a bare anthropic.cacheControl. Detection must fall back to classifying its ttl.
102+
it("classifies a legacy Anthropic cacheControl with no discriminator by its ttl", () => {
103+
const legacyPrefix = { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } };
104+
const legacyStepWithTtl = { anthropic: { cacheControl: STEP_CACHE_CONTROL } };
105+
const legacyStepNoTtl = { anthropic: { cacheControl: { type: "ephemeral" } } };
106+
107+
expect(isLongLivedCacheBreakpoint(legacyPrefix)).toBe(true);
108+
expect(isStepCacheBreakpoint(legacyPrefix)).toBe(false);
109+
expect(isStepCacheBreakpoint(legacyStepWithTtl)).toBe(true);
110+
expect(isLongLivedCacheBreakpoint(legacyStepWithTtl)).toBe(false);
111+
expect(isStepCacheBreakpoint(legacyStepNoTtl)).toBe(true);
112+
});
113+
114+
it("strips a legacy Anthropic cacheControl even while Bedrock is active", () => {
115+
useBedrock();
116+
const legacyStep = { anthropic: { cacheControl: STEP_CACHE_CONTROL, keep: true } };
117+
118+
expect(withoutCacheBreakpoint(legacyStep)).toEqual({ anthropic: { keep: true } });
119+
});
99120
});

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,13 @@ type ProviderOptions = Record<string, any> | undefined;
6969
const CACHE_BREAKPOINT_KEY = "__cacheBreakpoint";
7070

7171
function breakpointKind(providerOptions: ProviderOptions): CacheBreakpoint | undefined {
72-
return providerOptions?.[CACHE_BREAKPOINT_KEY]?.kind;
72+
const discriminated = providerOptions?.[CACHE_BREAKPOINT_KEY]?.kind;
73+
if (discriminated) return discriminated;
74+
// Conversations persisted before the discriminator existed carry a bare Anthropic
75+
// cacheControl. Classify it by ttl: "1h" is the turn-wide prefix, anything else the step.
76+
const legacyCacheControl = providerOptions?.anthropic?.cacheControl;
77+
if (!legacyCacheControl) return undefined;
78+
return legacyCacheControl.ttl === "1h" ? "prefix" : "step";
7379
}
7480

7581
function cacheOptions(breakpoint: CacheBreakpoint): Record<string, any> {
@@ -131,7 +137,15 @@ export function cacheUsageFromProviderMetadata(providerMetadata: unknown): {
131137

132138
/** The same options with the active provider's breakpoint and its discriminator removed. */
133139
export function withoutCacheBreakpoint(providerOptions: ProviderOptions): Record<string, any> {
134-
const key = dashboardAgentProvider() === "anthropic" ? "anthropic" : "bedrock";
140+
const hasDiscriminator = providerOptions?.[CACHE_BREAKPOINT_KEY] !== undefined;
141+
// A legacy message keeps its native anthropic.cacheControl shape no matter which
142+
// provider is active now, so strip that key rather than the current provider's.
143+
const isLegacy = !hasDiscriminator && providerOptions?.anthropic?.cacheControl !== undefined;
144+
const key = isLegacy
145+
? "anthropic"
146+
: dashboardAgentProvider() === "anthropic"
147+
? "anthropic"
148+
: "bedrock";
135149
const field = key === "anthropic" ? "cacheControl" : "cachePoint";
136150
const {
137151
[key]: provider,

internal-packages/dashboard-agent/src/step-cache.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,21 @@ describe("the step cache breakpoint", () => {
131131
const empty: Message[] = [];
132132
expect(markStepCacheBreakpoint(empty)).toBe(empty);
133133
});
134+
135+
// A conversation resumed after this branch shipped can still carry a step
136+
// breakpoint in the pre-discriminator shape. It must be stripped like any other.
137+
it("strips a legacy step breakpoint on resume", () => {
138+
const legacyStep: Message = {
139+
role: "tool",
140+
content: "ok",
141+
providerOptions: { anthropic: { cacheControl: STEP_CACHE_CONTROL, keep: true } },
142+
};
143+
const marked = markStepCacheBreakpoint([turnHistory(), legacyStep, toolResult(20)]);
144+
145+
expect(ttlOf(marked[1])).toBeUndefined();
146+
expect(marked[1]!.providerOptions).toEqual({ anthropic: { keep: true } });
147+
expect(ttlOf(marked[0])).toBe("1h");
148+
});
134149
});
135150

136151
describe("the step cache breakpoint on Bedrock", () => {

0 commit comments

Comments
 (0)