@@ -13,7 +13,7 @@ import {
1313} from "../../features/magic-context/storage-meta-persisted" ;
1414import { Database } from "../../shared/sqlite" ;
1515import { closeQuietly } from "../../shared/sqlite-helpers" ;
16- import { createEventHook , createToolExecuteAfterHook } from "./hook-handlers" ;
16+ import { createChatMessageHook , createEventHook , createToolExecuteAfterHook } from "./hook-handlers" ;
1717
1818function 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+ } ) ;
0 commit comments