@@ -280,6 +280,31 @@ const outputRetryLLM = Layer.succeed(
280280const outputRetryEnv = LayerNode . compile ( root , [ ...replacements , [ LLM . node , outputRetryLLM ] ] )
281281const itOutputRetry = testEffect ( outputRetryEnv )
282282
283+ function reasoningOnly ( text : string ) {
284+ return Stream . make (
285+ LLMEvent . stepStart ( { index : 0 } ) ,
286+ LLMEvent . reasoningStart ( { id : "reasoning-1" } ) ,
287+ LLMEvent . reasoningDelta ( { id : "reasoning-1" , text } ) ,
288+ LLMEvent . reasoningEnd ( { id : "reasoning-1" } ) ,
289+ LLMEvent . stepFinish ( { index : 0 , reason : "stop" } ) ,
290+ LLMEvent . finish ( { reason : "stop" } ) ,
291+ )
292+ }
293+
294+ const semanticRetryInputs : LLM . StreamInput [ ] = [ ]
295+ const semanticRetryStreams : Stream . Stream < LLMEvent > [ ] = [ ]
296+ const semanticRetryLLM = Layer . succeed (
297+ LLM . Service ,
298+ LLM . Service . of ( {
299+ stream : ( input ) => {
300+ semanticRetryInputs . push ( input )
301+ return semanticRetryStreams . shift ( ) ?? Stream . fail ( new Error ( "missing semantic retry stream" ) )
302+ } ,
303+ } ) ,
304+ )
305+ const semanticRetryEnv = LayerNode . compile ( root , [ ...replacements , [ LLM . node , semanticRetryLLM ] ] )
306+ const itSemanticRetry = testEffect ( semanticRetryEnv )
307+
283308const boot = Effect . fn ( "test.boot" ) ( function * ( ) {
284309 const processors = yield * SessionProcessor . Service
285310 const session = yield * Session . Service
@@ -521,6 +546,157 @@ it.live("session.processor effect tests capture reasoning from http mock", () =>
521546 ) ,
522547)
523548
549+ itSemanticRetry . live ( "session.processor effect tests retry a reasoning-only response once" , ( ) =>
550+ provideTmpdirInstance ( ( dir ) =>
551+ Effect . gen ( function * ( ) {
552+ const { processors, session } = yield * boot ( )
553+ semanticRetryInputs . length = 0
554+ semanticRetryStreams . length = 0
555+ semanticRetryStreams . push (
556+ reasoningOnly ( "unfinished" ) ,
557+ Stream . make (
558+ LLMEvent . stepStart ( { index : 0 } ) ,
559+ LLMEvent . textStart ( { id : "text-1" } ) ,
560+ LLMEvent . textDelta ( { id : "text-1" , text : "done" } ) ,
561+ LLMEvent . textEnd ( { id : "text-1" } ) ,
562+ LLMEvent . stepFinish ( { index : 0 , reason : "stop" } ) ,
563+ LLMEvent . finish ( { reason : "stop" } ) ,
564+ ) ,
565+ )
566+
567+ const chat = yield * session . create ( { } )
568+ const parent = yield * user ( chat . id , "reason" )
569+ const msg = yield * assistant ( chat . id , parent . id , path . resolve ( dir ) )
570+ const handle = yield * processors . create ( {
571+ assistantMessage : msg ,
572+ sessionID : chat . id ,
573+ model : outputRetryModel ,
574+ } )
575+
576+ const input = {
577+ user : {
578+ id : parent . id ,
579+ sessionID : chat . id ,
580+ role : "user" ,
581+ time : parent . time ,
582+ agent : parent . agent ,
583+ model : { providerID : ref . providerID , modelID : ref . modelID } ,
584+ } satisfies SessionV1 . User ,
585+ sessionID : chat . id ,
586+ model : outputRetryModel ,
587+ agent : agent ( ) ,
588+ system : [ ] ,
589+ messages : [ { role : "user" , content : "reason" } ] ,
590+ tools : { } ,
591+ } satisfies LLM . StreamInput
592+
593+ const value = yield * handle . process ( input )
594+ const parts = yield * MessageV2 . parts ( msg . id )
595+
596+ expect ( value ) . toBe ( "continue" )
597+ expect ( semanticRetryInputs ) . toHaveLength ( 2 )
598+ expect ( semanticRetryInputs [ 1 ] ) . toBe ( semanticRetryInputs [ 0 ] )
599+ expect ( parts . some ( ( part ) => part . type === "reasoning" && part . text === "unfinished" ) ) . toBe ( true )
600+ expect ( parts . some ( ( part ) => part . type === "text" && part . text === "done" ) ) . toBe ( true )
601+ expect ( handle . message . error ) . toBeUndefined ( )
602+ } ) ,
603+ ) ,
604+ )
605+
606+ itSemanticRetry . live ( "session.processor effect tests fail after two reasoning-only responses" , ( ) =>
607+ provideTmpdirInstance ( ( dir ) =>
608+ Effect . gen ( function * ( ) {
609+ const { processors, session } = yield * boot ( )
610+ semanticRetryInputs . length = 0
611+ semanticRetryStreams . length = 0
612+ semanticRetryStreams . push ( reasoningOnly ( "one" ) , reasoningOnly ( "two" ) )
613+
614+ const chat = yield * session . create ( { } )
615+ const parent = yield * user ( chat . id , "reason" )
616+ const msg = yield * assistant ( chat . id , parent . id , path . resolve ( dir ) )
617+ const handle = yield * processors . create ( {
618+ assistantMessage : msg ,
619+ sessionID : chat . id ,
620+ model : outputRetryModel ,
621+ } )
622+
623+ const value = yield * handle . process ( {
624+ user : {
625+ id : parent . id ,
626+ sessionID : chat . id ,
627+ role : "user" ,
628+ time : parent . time ,
629+ agent : parent . agent ,
630+ model : { providerID : ref . providerID , modelID : ref . modelID } ,
631+ } satisfies SessionV1 . User ,
632+ sessionID : chat . id ,
633+ model : outputRetryModel ,
634+ agent : agent ( ) ,
635+ system : [ ] ,
636+ messages : [ { role : "user" , content : "reason" } ] ,
637+ tools : { } ,
638+ } )
639+
640+ expect ( value ) . toBe ( "stop" )
641+ expect ( semanticRetryInputs ) . toHaveLength ( 2 )
642+ expect ( handle . message . error ) . toMatchObject ( {
643+ name : "APIError" ,
644+ data : { message : "Model returned reasoning without an answer or tool call" } ,
645+ } )
646+ } ) ,
647+ ) ,
648+ )
649+
650+ itSemanticRetry . live ( "session.processor effect tests do not retry unterminated nonblank text" , ( ) =>
651+ provideTmpdirInstance ( ( dir ) =>
652+ Effect . gen ( function * ( ) {
653+ const { processors, session } = yield * boot ( )
654+ semanticRetryInputs . length = 0
655+ semanticRetryStreams . length = 0
656+ semanticRetryStreams . push (
657+ Stream . make (
658+ LLMEvent . stepStart ( { index : 0 } ) ,
659+ LLMEvent . textStart ( { id : "text-1" } ) ,
660+ LLMEvent . textDelta ( { id : "text-1" , text : "visible" } ) ,
661+ LLMEvent . stepFinish ( { index : 0 , reason : "stop" } ) ,
662+ LLMEvent . finish ( { reason : "stop" } ) ,
663+ ) ,
664+ )
665+
666+ const chat = yield * session . create ( { } )
667+ const parent = yield * user ( chat . id , "reason" )
668+ const msg = yield * assistant ( chat . id , parent . id , path . resolve ( dir ) )
669+ const handle = yield * processors . create ( {
670+ assistantMessage : msg ,
671+ sessionID : chat . id ,
672+ model : outputRetryModel ,
673+ } )
674+
675+ const value = yield * handle . process ( {
676+ user : {
677+ id : parent . id ,
678+ sessionID : chat . id ,
679+ role : "user" ,
680+ time : parent . time ,
681+ agent : parent . agent ,
682+ model : { providerID : ref . providerID , modelID : ref . modelID } ,
683+ } satisfies SessionV1 . User ,
684+ sessionID : chat . id ,
685+ model : outputRetryModel ,
686+ agent : agent ( ) ,
687+ system : [ ] ,
688+ messages : [ { role : "user" , content : "reason" } ] ,
689+ tools : { } ,
690+ } )
691+ const parts = yield * MessageV2 . parts ( msg . id )
692+
693+ expect ( value ) . toBe ( "continue" )
694+ expect ( semanticRetryInputs ) . toHaveLength ( 1 )
695+ expect ( parts . some ( ( part ) => part . type === "text" && part . text === "visible" ) ) . toBe ( true )
696+ } ) ,
697+ ) ,
698+ )
699+
524700it . live ( "session.processor effect tests reset reasoning state across retries" , ( ) =>
525701 provideTmpdirServer (
526702 ( { dir, llm } ) =>
0 commit comments