Skip to content

Commit 7d22306

Browse files
committed
fix(cache): defer variant-change flush on providers whose cache ignores request params (#257)
A reasoning-variant flip maps to a thinking-config change. On the Anthropic family the provider renders that config into the prompt and busts message cache itself, so draining queued ops rides a natural bust. On implicit-prefix providers the config is a request parameter outside the cache key, so MC's flush was the only bust — gratuitous (104 ops drained at 54% usage, 156K uncached tokens in the report). Gate the flush on a provider predicate; deferred ops ride the next natural bust. Fixes #257.
2 parents 3c0dbe4 + 5b3e0e0 commit 7d22306

3 files changed

Lines changed: 277 additions & 9 deletions

File tree

packages/plugin/src/hooks/magic-context/hook-handlers.test.ts

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from "../../features/magic-context/storage-meta-persisted";
1414
import { Database } from "../../shared/sqlite";
1515
import { closeQuietly } from "../../shared/sqlite-helpers";
16-
import { createEventHook, createToolExecuteAfterHook } from "./hook-handlers";
16+
import { createChatMessageHook, createEventHook, createToolExecuteAfterHook } from "./hook-handlers";
1717

1818
function createTestDb(): Database {
1919
const db = new Database(":memory:");
@@ -309,3 +309,189 @@ describe("createEventHook mid-session model switch clears overflow state", () =>
309309
}
310310
});
311311
});
312+
313+
// Variant-change flush must be provider-aware (#257). On providers whose wire
314+
// renders the thinking config into the prompt (Anthropic family), the
315+
// provider itself invalidates message blocks on a variant flip, so our flush
316+
// rides a bust that happens regardless. On implicit-prefix-caching providers
317+
// (OpenAI-compatible et al.), reasoning_effort/budget is a request parameter
318+
// outside the cache key, so a variant flip is a full cache HIT and our flush
319+
// would be the ONLY bust — a gratuitous one that drains queued ops for no
320+
// provider-side reason. The gate defers the flush on the latter.
321+
describe("createChatMessageHook variant-change flush is provider-aware", () => {
322+
type Sets = {
323+
historyRefreshSessions: Set<string>;
324+
systemPromptRefreshSessions: Set<string>;
325+
pendingMaterializationSessions: Set<string>;
326+
lastHeuristicsTurnId: Map<string, string>;
327+
};
328+
329+
function makeHook(sets: Sets, liveModelBySession = new Map<string, { providerID: string; modelID: string }>()) {
330+
const db = createTestDb();
331+
const hook = createChatMessageHook({
332+
db,
333+
liveModelBySession,
334+
variantBySession: new Map<string, string | undefined>(),
335+
agentBySession: new Map<string, string>(),
336+
historyRefreshSessions: sets.historyRefreshSessions,
337+
systemPromptRefreshSessions: sets.systemPromptRefreshSessions,
338+
pendingMaterializationSessions: sets.pendingMaterializationSessions,
339+
lastHeuristicsTurnId: sets.lastHeuristicsTurnId,
340+
});
341+
return { hook, db };
342+
}
343+
344+
function freshSets(): Sets {
345+
return {
346+
historyRefreshSessions: new Set<string>(),
347+
systemPromptRefreshSessions: new Set<string>(),
348+
pendingMaterializationSessions: new Set<string>(),
349+
lastHeuristicsTurnId: new Map<string, string>(),
350+
};
351+
}
352+
353+
// Pins current behavior on the Anthropic family. Deleting the predicate
354+
// call (so the gate always takes the TRUE arm) must leave this test green.
355+
test("anthropic provider: variant flip signals all three sets + clears lastHeuristicsTurnId", async () => {
356+
const sets = freshSets();
357+
sets.lastHeuristicsTurnId.set("ses", "turn-1");
358+
const { hook, db } = makeHook(sets);
359+
try {
360+
await hook({ sessionID: "ses", variant: "low", model: { providerID: "anthropic", modelID: "claude" } });
361+
await hook({ sessionID: "ses", variant: "high", model: { providerID: "anthropic", modelID: "claude" } });
362+
363+
expect(sets.historyRefreshSessions.has("ses")).toBe(true);
364+
expect(sets.systemPromptRefreshSessions.has("ses")).toBe(true);
365+
expect(sets.pendingMaterializationSessions.has("ses")).toBe(true);
366+
expect(sets.lastHeuristicsTurnId.has("ses")).toBe(false);
367+
} finally {
368+
closeQuietly(db);
369+
}
370+
});
371+
372+
test("bedrock provider: variant flip signals all three sets (thinking config rendered into prompt)", async () => {
373+
const sets = freshSets();
374+
const { hook, db } = makeHook(sets);
375+
try {
376+
await hook({ sessionID: "ses", variant: "low", model: { providerID: "bedrock", modelID: "claude" } });
377+
await hook({ sessionID: "ses", variant: "high", model: { providerID: "bedrock", modelID: "claude" } });
378+
379+
expect(sets.historyRefreshSessions.has("ses")).toBe(true);
380+
expect(sets.systemPromptRefreshSessions.has("ses")).toBe(true);
381+
expect(sets.pendingMaterializationSessions.has("ses")).toBe(true);
382+
} finally {
383+
closeQuietly(db);
384+
}
385+
});
386+
387+
test("google-vertex-anthropic provider: variant flip signals all three sets", async () => {
388+
const sets = freshSets();
389+
const { hook, db } = makeHook(sets);
390+
try {
391+
await hook({
392+
sessionID: "ses",
393+
variant: "low",
394+
model: { providerID: "google-vertex-anthropic", modelID: "claude" },
395+
});
396+
await hook({
397+
sessionID: "ses",
398+
variant: "high",
399+
model: { providerID: "google-vertex-anthropic", modelID: "claude" },
400+
});
401+
402+
expect(sets.historyRefreshSessions.has("ses")).toBe(true);
403+
expect(sets.systemPromptRefreshSessions.has("ses")).toBe(true);
404+
expect(sets.pendingMaterializationSessions.has("ses")).toBe(true);
405+
} finally {
406+
closeQuietly(db);
407+
}
408+
});
409+
410+
// Mutation-sensitive: this test MUST FAIL if the predicate is hardcoded
411+
// true or the gate is removed. We assert the sets are EMPTY (not merely
412+
// "not containing extra members") to kill the deleted-effect mutant.
413+
test("openai provider: variant flip signals NOTHING and leaves lastHeuristicsTurnId untouched", async () => {
414+
const sets = freshSets();
415+
sets.lastHeuristicsTurnId.set("ses", "turn-1");
416+
const { hook, db } = makeHook(sets);
417+
try {
418+
await hook({ sessionID: "ses", variant: "low", model: { providerID: "openai", modelID: "gpt-4o" } });
419+
await hook({ sessionID: "ses", variant: "high", model: { providerID: "openai", modelID: "gpt-4o" } });
420+
421+
expect(sets.historyRefreshSessions.size).toBe(0);
422+
expect(sets.systemPromptRefreshSessions.size).toBe(0);
423+
expect(sets.pendingMaterializationSessions.size).toBe(0);
424+
expect(sets.lastHeuristicsTurnId.get("ses")).toBe("turn-1");
425+
} finally {
426+
closeQuietly(db);
427+
}
428+
});
429+
430+
test("fireworks provider: variant flip signals NOTHING (implicit-prefix cache, request param outside cache key)", async () => {
431+
const sets = freshSets();
432+
const { hook, db } = makeHook(sets);
433+
try {
434+
await hook({ sessionID: "ses", variant: "low", model: { providerID: "fireworks", modelID: "fwm" } });
435+
await hook({ sessionID: "ses", variant: "high", model: { providerID: "fireworks", modelID: "fwm" } });
436+
437+
expect(sets.historyRefreshSessions.size).toBe(0);
438+
expect(sets.systemPromptRefreshSessions.size).toBe(0);
439+
expect(sets.pendingMaterializationSessions.size).toBe(0);
440+
} finally {
441+
closeQuietly(db);
442+
}
443+
});
444+
445+
// Unknown provider (no model info on the hook input) takes the
446+
// conservative TRUE arm = today's behavior.
447+
test("unknown provider (no model info): variant flip takes the TRUE arm (all three sets signaled)", async () => {
448+
const sets = freshSets();
449+
const { hook, db } = makeHook(sets);
450+
try {
451+
await hook({ sessionID: "ses", variant: "low" });
452+
await hook({ sessionID: "ses", variant: "high" });
453+
454+
expect(sets.historyRefreshSessions.has("ses")).toBe(true);
455+
expect(sets.systemPromptRefreshSessions.has("ses")).toBe(true);
456+
expect(sets.pendingMaterializationSessions.has("ses")).toBe(true);
457+
} finally {
458+
closeQuietly(db);
459+
}
460+
});
461+
462+
// The liveModelBySession fallback: when the hook input has no model but a
463+
// prior event recorded the provider, the fallback providerID governs the
464+
// gate. An OpenAI-compatible provider recorded earlier must still defer.
465+
test("liveModelBySession fallback: openai recorded earlier, no model on input → defer (FALSE arm)", async () => {
466+
const sets = freshSets();
467+
const liveModelBySession = new Map<string, { providerID: string; modelID: string }>([
468+
["ses", { providerID: "openai", modelID: "gpt-4o" }],
469+
]);
470+
const { hook, db } = makeHook(sets, liveModelBySession);
471+
try {
472+
await hook({ sessionID: "ses", variant: "low" });
473+
await hook({ sessionID: "ses", variant: "high" });
474+
475+
expect(sets.historyRefreshSessions.size).toBe(0);
476+
expect(sets.systemPromptRefreshSessions.size).toBe(0);
477+
expect(sets.pendingMaterializationSessions.size).toBe(0);
478+
} finally {
479+
closeQuietly(db);
480+
}
481+
});
482+
483+
test("no variant change: no sets signaled regardless of provider", async () => {
484+
const sets = freshSets();
485+
const { hook, db } = makeHook(sets);
486+
try {
487+
await hook({ sessionID: "ses", variant: "high", model: { providerID: "openai", modelID: "gpt-4o" } });
488+
await hook({ sessionID: "ses", variant: "high", model: { providerID: "openai", modelID: "gpt-4o" } });
489+
490+
expect(sets.historyRefreshSessions.size).toBe(0);
491+
expect(sets.systemPromptRefreshSessions.size).toBe(0);
492+
expect(sets.pendingMaterializationSessions.size).toBe(0);
493+
} finally {
494+
closeQuietly(db);
495+
}
496+
});
497+
});

packages/plugin/src/hooks/magic-context/hook-handlers.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
resetNoteNudgeCooldownOnly,
4646
} from "./note-nudger";
4747
import { readRawSessionMessageById, readRawSessionMessages } from "./read-session-chunk";
48+
import { variantChangeBustsProviderCache } from "./sentinel";
4849
import { normalizeTodoStateJson } from "./todo-view";
4950

5051
export type LiveModelBySession = Map<string, { providerID: string; modelID: string }>;
@@ -203,14 +204,49 @@ export function createChatMessageHook(args: {
203204
input.variant !== undefined &&
204205
previousVariant !== input.variant
205206
) {
206-
sessionLog(
207-
sessionId,
208-
`variant changed (${previousVariant} -> ${input.variant}), triggering flush`,
209-
);
210-
args.historyRefreshSessions.add(sessionId);
211-
args.systemPromptRefreshSessions.add(sessionId);
212-
args.pendingMaterializationSessions.add(sessionId);
213-
args.lastHeuristicsTurnId.delete(sessionId);
207+
// A reasoning-variant change maps to a thinking-config change
208+
// (effort / budget_tokens / toggle — see OpenCode's
209+
// `reasoningVariants`). Whether that busts the provider's prompt
210+
// cache on its own depends on the provider's cache model: the
211+
// Anthropic family renders the thinking config into the prompt
212+
// (so the provider itself invalidates message blocks on a change
213+
// and our queued ops drain on that natural bust), while
214+
// OpenAI-compatible providers carry reasoning_effort / budget as
215+
// a request parameter outside the cache key, so a variant flip
216+
// is a full cache HIT and our flush would be the ONLY bust — a
217+
// gratuitous one. See `variantChangeBustsProviderCache` for the
218+
// full rationale and the safety asymmetry.
219+
//
220+
// providerID comes from the hook input (the live request's
221+
// model) with `liveModelBySession` as a fallback for sessions
222+
// whose first chat.message predates a model-bearing event. When
223+
// no provider is known yet we take the conservative TRUE arm
224+
// (today's behavior) so we never silently drop a needed drain.
225+
const providerID =
226+
input.model?.providerID ??
227+
args.liveModelBySession.get(sessionId)?.providerID;
228+
if (variantChangeBustsProviderCache(providerID)) {
229+
sessionLog(
230+
sessionId,
231+
`variant changed (${previousVariant} -> ${input.variant}), triggering flush`,
232+
);
233+
args.historyRefreshSessions.add(sessionId);
234+
args.systemPromptRefreshSessions.add(sessionId);
235+
args.pendingMaterializationSessions.add(sessionId);
236+
args.lastHeuristicsTurnId.delete(sessionId);
237+
} else {
238+
// The provider's cache ignores request params, so a variant
239+
// flip is a cache HIT. Defer the queued ops to the next
240+
// natural bust (fold / threshold / TTL / flush) exactly as
241+
// historian publications do — do NOT manufacture a bust here.
242+
// This log line also answers the dashboard-mislabeling
243+
// complaint at the log level: the variant change was observed
244+
// but the flush was deferred, not triggered.
245+
sessionLog(
246+
sessionId,
247+
`variant changed (${previousVariant} -> ${input.variant}) on provider ${providerID} whose cache ignores request params; deferring flush to next natural bust`,
248+
);
249+
}
214250
}
215251
};
216252
}

packages/plugin/src/hooks/magic-context/sentinel.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,52 @@ export function modelAcceptsEmptyContent(providerID?: string): boolean {
3636
return providerID === "anthropic";
3737
}
3838

39+
/**
40+
* Decide whether a reasoning-variant change (effort / budget_tokens / toggle)
41+
* busts the provider's prompt cache on its own.
42+
*
43+
* Two provider cache models govern this:
44+
*
45+
* - Thinking-config-in-prompt (Anthropic family): the reasoning
46+
* configuration is rendered into the prompt itself, so changing it
47+
* re-serializes the affected message blocks. Anthropic's docs state that
48+
* changing the thinking config "always invalidates message blocks"; the
49+
* same holds on Bedrock. The bust therefore happens regardless of our
50+
* flush, and our queued ops drain on that natural bust.
51+
*
52+
* - Request-param-outside-cache-key (OpenAI-compatible et al.):
53+
* reasoning_effort / budget is a request parameter that lives outside the
54+
* cache key. A variant flip does NOT re-serialize any cached bytes, so the
55+
* provider serves the whole prefix as a cache HIT. Our flush would be the
56+
* ONLY bust — manufacturing a gratuitous one that re-runs heuristics and
57+
* re-materializes drops for no provider-side reason (reporter's evidence:
58+
* 104 pending ops drained at 54% usage, 156K uncached tokens, while a
59+
* no-op variant flip on the same provider was a full cache HIT).
60+
*
61+
* Safety asymmetry: misclassifying toward TRUE keeps today's behavior
62+
* (wasteful but correct — the ops still drain). Misclassifying toward FALSE
63+
* only defers queued ops to the next natural bust (fold / threshold / TTL /
64+
* flush), which is the already-designed steady-state for historian publishes
65+
* (ARCHITECTURE.md invariant 3: deferred work rides the next bust cycle, it
66+
* never forces its own). So the conservative default for unknown providers is
67+
* TRUE (today's behavior).
68+
*
69+
* Provider IDs matched (per OpenCode's `provider/transform.ts`):
70+
* - `anthropic` — canonical @ai-sdk/anthropic
71+
* - `bedrock` — @ai-sdk/amazon-bedrock (reported as "bedrock")
72+
* - `google-vertex-anthropic` — @ai-sdk/google-vertex/anthropic
73+
* The `bedrock` check uses `includes` so any future bedrock-derived providerID
74+
* (e.g. a mantle variant) is also covered, matching how OpenCode's own
75+
* `useMessageLevelOptions` gate matches bedrock.
76+
*/
77+
export function variantChangeBustsProviderCache(providerID?: string): boolean {
78+
if (providerID === undefined) return true;
79+
if (providerID === "anthropic") return true;
80+
if (providerID === "google-vertex-anthropic") return true;
81+
if (providerID.includes("bedrock")) return true;
82+
return false;
83+
}
84+
3985
/**
4086
* Create an empty-text sentinel to replace a stripped message PART (not a
4187
* whole message) while preserving the array's length and index positions

0 commit comments

Comments
 (0)