@@ -482,6 +482,37 @@ describe("wrapAnthropicStreamWithRecovery", () => {
482482 const anthropicThinkingError = new Error (
483483 "thinking or redacted_thinking blocks in the latest assistant message cannot be modified" ,
484484 ) ;
485+ const terminalThinkingSignatureError =
486+ "ValidationException: invalid signature on thinking block in message history" ;
487+
488+ function createTestAssistantMessage (
489+ overrides : Partial < AssistantMessage > & Pick < AssistantMessage , "content" | "stopReason" > ,
490+ ) : AssistantMessage {
491+ return castAgentMessage ( {
492+ role : "assistant" ,
493+ api : "anthropic-messages" ,
494+ provider : "anthropic" ,
495+ model : "claude-sonnet-4-6" ,
496+ usage : {
497+ input : 0 ,
498+ output : 0 ,
499+ cacheRead : 0 ,
500+ cacheWrite : 0 ,
501+ totalTokens : 0 ,
502+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
503+ } ,
504+ timestamp : 0 ,
505+ ...overrides ,
506+ } ) as AssistantMessage ;
507+ }
508+
509+ function createTestStreamErrorMessage ( errorMessage : string ) : AssistantMessage {
510+ return createTestAssistantMessage ( {
511+ content : [ { type : "text" , text : "stream failed" } ] ,
512+ stopReason : "error" ,
513+ errorMessage,
514+ } ) ;
515+ }
485516
486517 it ( "retries once with omitted-reasoning text when the request is rejected before streaming" , async ( ) => {
487518 let callCount = 0 ;
@@ -584,6 +615,131 @@ describe("wrapAnthropicStreamWithRecovery", () => {
584615 expect ( callCount ) . toBe ( 2 ) ;
585616 } ) ;
586617
618+ it ( "retries pre-content terminal stream-error events with omitted-reasoning text" , async ( ) => {
619+ let callCount = 0 ;
620+ const contexts : Array < { messages ?: AgentMessage [ ] } > = [ ] ;
621+ const finalMessage = createTestAssistantMessage ( {
622+ content : [ { type : "text" , text : "recovered" } ] ,
623+ stopReason : "stop" ,
624+ } ) ;
625+ const wrapped = wrapAnthropicStreamWithRecovery (
626+ ( ( _model , context ) => {
627+ callCount += 1 ;
628+ const attempt = callCount ;
629+ contexts . push ( context as { messages ?: AgentMessage [ ] } ) ;
630+ const stream = createAssistantMessageEventStream ( ) ;
631+ queueMicrotask ( ( ) => {
632+ if ( attempt === 1 ) {
633+ stream . push ( {
634+ type : "error" ,
635+ reason : "error" ,
636+ error : createTestStreamErrorMessage ( terminalThinkingSignatureError ) ,
637+ } ) ;
638+ } else {
639+ stream . push ( { type : "done" , reason : "stop" , message : finalMessage } ) ;
640+ }
641+ stream . end ( ) ;
642+ } ) ;
643+ return stream ;
644+ } ) as Parameters < typeof wrapAnthropicStreamWithRecovery > [ 0 ] ,
645+ { id : "test-session" } ,
646+ ) ;
647+
648+ const response = wrapped (
649+ { } as never ,
650+ {
651+ messages : castAgentMessages ( [
652+ {
653+ role : "assistant" ,
654+ content : [ { type : "thinking" , thinking : "secret" , thinkingSignature : "sig" } ] ,
655+ } ,
656+ ] ) ,
657+ } as never ,
658+ { } as never ,
659+ ) as { result : ( ) => Promise < unknown > } & AsyncIterable < unknown > ;
660+ const events : unknown [ ] = [ ] ;
661+ for await ( const event of response ) {
662+ events . push ( event ) ;
663+ }
664+
665+ expect ( events ) . toEqual ( [ { type : "done" , reason : "stop" , message : finalMessage } ] ) ;
666+ await expect ( response . result ( ) ) . resolves . toEqual ( finalMessage ) ;
667+ expect ( callCount ) . toBe ( 2 ) ;
668+ const retryMessage = contexts [ 1 ] ?. messages ?. [ 0 ] ;
669+ if ( ! retryMessage || retryMessage . role !== "assistant" ) {
670+ throw new Error ( "Expected Anthropic recovery retry to start with an assistant message" ) ;
671+ }
672+ expect ( retryMessage . content ) . toEqual ( [
673+ { type : "text" , text : OMITTED_ASSISTANT_REASONING_TEXT } ,
674+ ] ) ;
675+ } ) ;
676+
677+ it ( "does not retry non-thinking terminal stream-error events" , async ( ) => {
678+ let callCount = 0 ;
679+ const errorMessage = createTestStreamErrorMessage ( "rate limit exceeded" ) ;
680+ const wrapped = wrapAnthropicStreamWithRecovery (
681+ ( ( ) => {
682+ callCount += 1 ;
683+ const stream = createAssistantMessageEventStream ( ) ;
684+ queueMicrotask ( ( ) => {
685+ stream . push ( { type : "error" , reason : "error" , error : errorMessage } ) ;
686+ stream . end ( ) ;
687+ } ) ;
688+ return stream ;
689+ } ) as Parameters < typeof wrapAnthropicStreamWithRecovery > [ 0 ] ,
690+ { id : "test-session" } ,
691+ ) ;
692+
693+ const response = wrapped ( { } as never , { messages : [ ] } as never , { } as never ) as {
694+ result : ( ) => Promise < unknown > ;
695+ } & AsyncIterable < unknown > ;
696+ const events : unknown [ ] = [ ] ;
697+ for await ( const event of response ) {
698+ events . push ( event ) ;
699+ }
700+
701+ expect ( events ) . toEqual ( [ { type : "error" , reason : "error" , error : errorMessage } ] ) ;
702+ await expect ( response . result ( ) ) . resolves . toEqual ( errorMessage ) ;
703+ expect ( callCount ) . toBe ( 1 ) ;
704+ } ) ;
705+
706+ it ( "does not retry terminal stream-error events after output was yielded" , async ( ) => {
707+ let callCount = 0 ;
708+ const partialMessage = createTestAssistantMessage ( {
709+ content : [ { type : "text" , text : "" } ] ,
710+ stopReason : "stop" ,
711+ } ) ;
712+ const errorMessage = createTestStreamErrorMessage ( terminalThinkingSignatureError ) ;
713+ const wrapped = wrapAnthropicStreamWithRecovery (
714+ ( ( ) => {
715+ callCount += 1 ;
716+ const stream = createAssistantMessageEventStream ( ) ;
717+ queueMicrotask ( ( ) => {
718+ stream . push ( { type : "start" , partial : partialMessage } ) ;
719+ stream . push ( { type : "error" , reason : "error" , error : errorMessage } ) ;
720+ stream . end ( ) ;
721+ } ) ;
722+ return stream ;
723+ } ) as Parameters < typeof wrapAnthropicStreamWithRecovery > [ 0 ] ,
724+ { id : "test-session" } ,
725+ ) ;
726+
727+ const response = wrapped ( { } as never , { messages : [ ] } as never , { } as never ) as {
728+ result : ( ) => Promise < unknown > ;
729+ } & AsyncIterable < unknown > ;
730+ const events : unknown [ ] = [ ] ;
731+ for await ( const event of response ) {
732+ events . push ( event ) ;
733+ }
734+
735+ expect ( events ) . toEqual ( [
736+ { type : "start" , partial : partialMessage } ,
737+ { type : "error" , reason : "error" , error : errorMessage } ,
738+ ] ) ;
739+ await expect ( response . result ( ) ) . resolves . toEqual ( errorMessage ) ;
740+ expect ( callCount ) . toBe ( 1 ) ;
741+ } ) ;
742+
587743 it ( "does not retry when the stream fails after yielding a chunk" , async ( ) => {
588744 let callCount = 0 ;
589745 const wrapped = wrapAnthropicStreamWithRecovery (
0 commit comments