@@ -4,6 +4,7 @@ import type {
44} from "@agentclientprotocol/sdk" ;
55import type {
66 SDKAssistantMessage ,
7+ SDKModelRefusalFallbackMessage ,
78 SDKPartialAssistantMessage ,
89 SDKUserMessage ,
910} from "@anthropic-ai/claude-agent-sdk" ;
@@ -12,6 +13,7 @@ import { Logger } from "../../../utils/logger";
1213import type { Session } from "../types" ;
1314import {
1415 handleStreamEvent ,
16+ handleSystemMessage ,
1517 handleUserAssistantMessage ,
1618 type MessageHandlerContext ,
1719 stripMarkerTags ,
@@ -64,10 +66,14 @@ describe("stripMarkerTags", () => {
6466
6567function createHandlerContext ( ) {
6668 const updates : SessionNotification [ ] = [ ] ;
69+ const notifications : Array < { method : string ; params : unknown } > = [ ] ;
6770 const client = {
6871 sessionUpdate : async ( notification : SessionNotification ) => {
6972 updates . push ( notification ) ;
7073 } ,
74+ extNotification : async ( method : string , params : unknown ) => {
75+ notifications . push ( { method, params } ) ;
76+ } ,
7177 } as unknown as AgentSideConnection ;
7278 const context : MessageHandlerContext = {
7379 session : {
@@ -86,7 +92,7 @@ function createHandlerContext() {
8692 thinkingIds : new Set ( ) ,
8793 } ,
8894 } ;
89- return { context, updates } ;
95+ return { context, updates, notifications } ;
9096}
9197
9298function streamEvent (
@@ -361,3 +367,72 @@ describe("import replay (no client-side history)", () => {
361367 expect ( chunkTexts ( updates , "agent_message_chunk" ) ) . toEqual ( [ ] ) ;
362368 } ) ;
363369} ) ;
370+
371+ describe ( "handleSystemMessage model_refusal_fallback" , ( ) => {
372+ function refusalFallbackMessage (
373+ overrides : Partial < SDKModelRefusalFallbackMessage > = { } ,
374+ ) : SDKModelRefusalFallbackMessage {
375+ return {
376+ type : "system" ,
377+ subtype : "model_refusal_fallback" ,
378+ trigger : "refusal" ,
379+ direction : "retry" ,
380+ original_model : "claude-fable-5" ,
381+ fallback_model : "claude-opus-4-8" ,
382+ request_id : "req_1" ,
383+ api_refusal_category : "cyber" ,
384+ api_refusal_explanation : "This request was declined." ,
385+ retracted_message_uuids : [ ] ,
386+ content : "Retried on fallback model" ,
387+ uuid : "00000000-0000-0000-0000-000000000009" ,
388+ session_id : "test-session" ,
389+ ...overrides ,
390+ } ;
391+ }
392+
393+ it . each <
394+ [ string , Partial < SDKModelRefusalFallbackMessage > , Record < string , unknown > ]
395+ > ( [
396+ [
397+ "emits a refusal_fallback status notification with the model swap" ,
398+ { } ,
399+ {
400+ sessionId : "test-session" ,
401+ status : "refusal_fallback" ,
402+ fromModel : "claude-fable-5" ,
403+ toModel : "claude-opus-4-8" ,
404+ explanation : "This request was declined." ,
405+ } ,
406+ ] ,
407+ [
408+ "omits the explanation when the refused response carried none" ,
409+ { api_refusal_explanation : null } ,
410+ {
411+ sessionId : "test-session" ,
412+ status : "refusal_fallback" ,
413+ fromModel : "claude-fable-5" ,
414+ toModel : "claude-opus-4-8" ,
415+ } ,
416+ ] ,
417+ ] ) ( "%s" , async ( _name , overrides , expectedParams ) => {
418+ const { context, updates, notifications } = createHandlerContext ( ) ;
419+
420+ await handleSystemMessage ( refusalFallbackMessage ( overrides ) , context ) ;
421+
422+ expect ( updates ) . toEqual ( [ ] ) ;
423+ expect ( notifications ) . toEqual ( [
424+ { method : "_posthog/status" , params : expectedParams } ,
425+ ] ) ;
426+ } ) ;
427+
428+ it . each ( [ "revert" , "sticky" ] as const ) (
429+ "skips the notification for the legacy %s direction" ,
430+ async ( direction ) => {
431+ const { context, notifications } = createHandlerContext ( ) ;
432+
433+ await handleSystemMessage ( refusalFallbackMessage ( { direction } ) , context ) ;
434+
435+ expect ( notifications ) . toEqual ( [ ] ) ;
436+ } ,
437+ ) ;
438+ } ) ;
0 commit comments