@@ -41,6 +41,28 @@ const ref = {
4141 modelID : ModelV2 . ID . make ( "test-model" ) ,
4242}
4343
44+ const outputRetryModel : Provider . Model = {
45+ id : ref . modelID ,
46+ providerID : ref . providerID ,
47+ api : { id : "test-model" , url : "https://example.com" , npm : "@ai-sdk/openai" } ,
48+ name : "Test Model" ,
49+ capabilities : {
50+ temperature : true ,
51+ reasoning : false ,
52+ attachment : false ,
53+ toolcall : true ,
54+ input : { text : true , audio : false , image : false , video : false , pdf : false } ,
55+ output : { text : true , audio : false , image : false , video : false , pdf : false } ,
56+ interleaved : false ,
57+ } ,
58+ cost : { input : 1 , output : 1 , cache : { read : 0 , write : 0 } } ,
59+ limit : { context : 100_000 , input : 100_000 , output : 10_000 } ,
60+ status : "active" ,
61+ options : { } ,
62+ headers : { } ,
63+ release_date : "2026-01-01" ,
64+ }
65+
4466const cfg = {
4567 provider : {
4668 test : {
@@ -226,6 +248,38 @@ const fragmentFailureLLM = Layer.succeed(
226248const fragmentFailureEnv = LayerNode . compile ( root , [ ...replacements , [ LLM . node , fragmentFailureLLM ] ] )
227249const itFragmentFailure = testEffect ( fragmentFailureEnv )
228250
251+ const outputRetryInputs : LLM . StreamInput [ ] = [ ]
252+ const outputRetryUsage = {
253+ truncated : { input : 3 , output : 5 } ,
254+ complete : { input : 7 , output : 11 } ,
255+ } as const
256+ const outputRetryLLM = Layer . succeed (
257+ LLM . Service ,
258+ LLM . Service . of ( {
259+ stream : ( input ) => {
260+ outputRetryInputs . push ( input )
261+ const first = outputRetryInputs . length === 1
262+ return Stream . make (
263+ LLMEvent . stepStart ( { index : 0 } ) ,
264+ LLMEvent . textStart ( { id : "text-1" } ) ,
265+ LLMEvent . textDelta ( { id : "text-1" , text : first ? "truncated" : "complete" } ) ,
266+ LLMEvent . textEnd ( { id : "text-1" } ) ,
267+ LLMEvent . stepFinish ( {
268+ index : 0 ,
269+ reason : first ? "length" : "stop" ,
270+ usage : {
271+ inputTokens : first ? outputRetryUsage . truncated . input : outputRetryUsage . complete . input ,
272+ outputTokens : first ? outputRetryUsage . truncated . output : outputRetryUsage . complete . output ,
273+ } ,
274+ } ) ,
275+ LLMEvent . finish ( { reason : first ? "length" : "stop" } ) ,
276+ )
277+ } ,
278+ } ) ,
279+ )
280+ const outputRetryEnv = LayerNode . compile ( root , [ ...replacements , [ LLM . node , outputRetryLLM ] ] )
281+ const itOutputRetry = testEffect ( outputRetryEnv )
282+
229283const boot = Effect . fn ( "test.boot" ) ( function * ( ) {
230284 const processors = yield * SessionProcessor . Service
231285 const session = yield * Session . Service
@@ -514,6 +568,58 @@ it.live("session.processor effect tests reset reasoning state across retries", (
514568 ) ,
515569)
516570
571+ itOutputRetry . live ( "session.processor effect tests resample the exact request after an output limit" , ( ) =>
572+ provideTmpdirInstance ( ( dir ) =>
573+ Effect . gen ( function * ( ) {
574+ const { processors, session } = yield * boot ( )
575+ outputRetryInputs . length = 0
576+
577+ const chat = yield * session . create ( { } )
578+ const parent = yield * user ( chat . id , "resample" )
579+ const msg = yield * assistant ( chat . id , parent . id , path . resolve ( dir ) )
580+ const handle = yield * processors . create ( {
581+ assistantMessage : msg ,
582+ sessionID : chat . id ,
583+ model : outputRetryModel ,
584+ } )
585+
586+ const value = yield * handle . process ( {
587+ user : {
588+ id : parent . id ,
589+ sessionID : chat . id ,
590+ role : "user" ,
591+ time : parent . time ,
592+ agent : parent . agent ,
593+ model : { providerID : ref . providerID , modelID : ref . modelID } ,
594+ } satisfies SessionV1 . User ,
595+ sessionID : chat . id ,
596+ model : outputRetryModel ,
597+ agent : agent ( ) ,
598+ system : [ ] ,
599+ messages : [ { role : "user" , content : "resample" } ] ,
600+ tools : { } ,
601+ } )
602+
603+ const parts = yield * MessageV2 . parts ( msg . id )
604+
605+ expect ( value ) . toBe ( "continue" )
606+ expect ( outputRetryInputs ) . toHaveLength ( 2 )
607+ expect ( outputRetryInputs [ 1 ] ) . toBe ( outputRetryInputs [ 0 ] )
608+ expect ( parts . filter ( ( part ) => part . type === "text" ) . map ( ( part ) => part . text ) ) . toStrictEqual ( [ "complete" ] )
609+ const finishes = parts . filter ( ( part ) => part . type === "step-finish" )
610+ const input = outputRetryUsage . truncated . input + outputRetryUsage . complete . input
611+ const output = outputRetryUsage . truncated . output + outputRetryUsage . complete . output
612+ expect ( finishes ) . toHaveLength ( 1 )
613+ expect ( finishes [ 0 ] ) . toMatchObject ( {
614+ reason : "stop" ,
615+ tokens : { input, output } ,
616+ } )
617+ expect ( finishes [ 0 ] ?. cost ) . toBeCloseTo ( ( input + output ) / 1_000_000 )
618+ expect ( handle . message . finish ) . toBe ( "stop" )
619+ } ) ,
620+ ) ,
621+ )
622+
517623it . live ( "session.processor effect tests do not retry unknown json errors" , ( ) =>
518624 provideTmpdirServer (
519625 ( { dir, llm } ) =>
0 commit comments