@@ -86,6 +86,10 @@ interface ProcessorContext extends Input {
8686 currentTextID : string | undefined
8787 reasoningMap : Record < string , SessionV1 . ReasoningPart >
8888 v2AssistantMessageID : SessionMessage . ID | undefined
89+ // Part id created just before the current attempt begins; parts with a
90+ // greater id were produced by the attempt and are discarded when it is
91+ // retried after a stream truncation.
92+ partFloor : PartID
8993}
9094
9195type StreamEvent = LLMEvent
@@ -128,6 +132,7 @@ export const layer = Layer.effect(
128132 currentTextID : undefined ,
129133 reasoningMap : { } ,
130134 v2AssistantMessageID : undefined ,
135+ partFloor : PartID . ascending ( ) ,
131136 }
132137 const mirrorAssistant = flags . experimentalEventSystem && ! input . assistantMessage . summary
133138 let aborted = false
@@ -395,7 +400,7 @@ export const layer = Layer.effect(
395400 time : { start : Date . now ( ) } ,
396401 metadata : value . providerMetadata ,
397402 }
398- yield * session . updatePart ( ctx . reasoningMap [ value . id ] )
403+ yield * session . updatePart ( ctx . reasoningMap [ value . id ] )
399404 return
400405
401406 case "reasoning-delta" :
@@ -701,6 +706,20 @@ export const layer = Layer.effect(
701706 usage : value . usage ?? new Usage ( { } ) ,
702707 metadata : value . providerMetadata ,
703708 } )
709+ // Detect stream truncation: the AI SDK reports the unmapped
710+ // fallback reason when the upstream provider stream ends without a
711+ // proper stop_reason. No usage and no output means the connection
712+ // was cut mid-generation, which is a transient failure that should
713+ // be retried.
714+ if ( value . reason === "unknown" && usage . tokens . output === 0 ) {
715+ return yield * Effect . fail (
716+ new SessionV1 . APIError ( {
717+ message : "Provider stream ended without a stop reason" ,
718+ isRetryable : true ,
719+ metadata : { code : "EmptyOther" } ,
720+ } ) ,
721+ )
722+ }
704723 if ( ! ctx . assistantMessage . summary ) {
705724 // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
706725 if ( mirrorAssistant ) {
@@ -846,6 +865,27 @@ export const layer = Layer.effect(
846865 }
847866 } )
848867
868+ // Discards every part the failed attempt persisted (anything created
869+ // after partFloor) so a successful retry replaces rather than appends to
870+ // the truncated content. The assistant message is created fresh per
871+ // process() call, so the floor scopes removal to this attempt's output.
872+ const discardAttempt = Effect . fn ( "SessionProcessor.discardAttempt" ) ( function * ( ) {
873+ const existing = yield * MessageV2 . parts ( ctx . assistantMessage . id ) . pipe (
874+ Effect . provideService ( Database . Service , database ) ,
875+ )
876+ for ( const part of existing ) {
877+ if ( part . id <= ctx . partFloor ) continue
878+ yield * session . removePart ( {
879+ sessionID : ctx . sessionID ,
880+ messageID : ctx . assistantMessage . id ,
881+ partID : part . id ,
882+ } )
883+ }
884+ ctx . currentText = undefined
885+ ctx . reasoningMap = { }
886+ ctx . toolcalls = { }
887+ } )
888+
849889 const cleanup = Effect . fn ( "SessionProcessor.cleanup" ) ( function * ( ) {
850890 if ( ctx . snapshot ) {
851891 const patch = yield * snapshot . patch ( ctx . snapshot )
@@ -933,6 +973,11 @@ export const layer = Layer.effect(
933973 yield * events . publish ( Session . Event . Error , { sessionID : ctx . sessionID , error } )
934974 return
935975 }
976+ // Retries are exhausted: drop the truncated attempt's partial parts so
977+ // the failed message doesn't keep an orphan step-start / partial text.
978+ if ( SessionV1 . APIError . isInstance ( error ) && error . data . metadata ?. code === "EmptyOther" ) {
979+ yield * discardAttempt ( )
980+ }
936981 if ( ! ctx . assistantMessage . summary ) {
937982 // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
938983 if ( mirrorAssistant ) {
@@ -959,6 +1004,9 @@ export const layer = Layer.effect(
9591004 slog . info ( "process" )
9601005 ctx . needsCompaction = false
9611006 ctx . shouldBreak = ( yield * config . get ( ) ) . experimental ?. continue_loop_on_deny !== true
1007+ // Record the high-water mark before any attempt persists parts so a
1008+ // truncation retry can discard exactly this call's output.
1009+ ctx . partFloor = PartID . ascending ( )
9621010
9631011 return yield * Effect . gen ( function * ( ) {
9641012 yield * Effect . gen ( function * ( ) {
@@ -1003,7 +1051,12 @@ export const layer = Layer.effect(
10031051 timestamp : DateTime . makeUnsafe ( Date . now ( ) ) ,
10041052 } )
10051053 : Effect . void
1006- return flushV2Fragments ( ) . pipe (
1054+ // Only stream truncations leave partial parts worth discarding;
1055+ // other retryable errors (rate limits, 5xx) retry untouched.
1056+ const truncated =
1057+ SessionV1 . APIError . isInstance ( info . error ) && info . error . data . metadata ?. code === "EmptyOther"
1058+ return ( truncated ? discardAttempt ( ) : Effect . void ) . pipe (
1059+ Effect . andThen ( flushV2Fragments ( ) ) ,
10071060 Effect . andThen ( event ) ,
10081061 Effect . andThen (
10091062 status . set ( ctx . sessionID , {
0 commit comments