@@ -1675,7 +1675,6 @@ void LogStreamDebugSessionEvent(SessionEvent evt)
16751675 var mainTraceUsageBaseline = 0 ;
16761676 long inputTokens = 0 , outputTokens = 0 , cachedInputTokens = 0 , cacheWriteInputTokens = 0 , reasoningOutputTokens = 0 ;
16771677 var llmCallCount = 0 ;
1678- var optimizedSessionChanged = false ;
16791678 Dictionary < int , SessionItem > ? streamingToolCallItemsByIndex = null ;
16801679 Dictionary < int , string > ? streamingToolNameByIndex = null ;
16811680 Dictionary < string , SessionItem > ? streamingToolCallItemsByCallId = null ;
@@ -1888,7 +1887,6 @@ async Task RestoreUndrainedGuidanceAsync()
18881887 [ .. result . Messages ] ,
18891888 jsonSerializerOptions : SessionPersistenceJsonOptions . Default ) ;
18901889 InvalidatePromptRequestSnapshot ( threadId , "auto_compaction" ) ;
1891- optimizedSessionChanged = true ;
18921890 await TrySaveSessionAsync ( agent , session , threadId ) ;
18931891 var contextUsage = await SaveContextUsageSnapshotAsync (
18941892 threadId ,
@@ -1940,10 +1938,7 @@ async Task RestoreUndrainedGuidanceAsync()
19401938 }
19411939 }
19421940
1943- async Task FailAndPersistTurnAsync (
1944- string errorMsg ,
1945- string errorCode ,
1946- bool saveCurrentSession = false )
1941+ async Task FailAndPersistTurnAsync ( string errorMsg , string errorCode )
19471942 {
19481943 FinalizeStreamingAgentMessage ( ) ;
19491944 FinalizeStreamingReasoning ( ) ;
@@ -1957,12 +1952,20 @@ async Task FailAndPersistTurnAsync(
19571952 FailTurn ( turn , eventChannel , errorMsg ) ;
19581953 ThreadRuntimeSignalForBroadcast ? . Invoke ( threadId , SessionThreadRuntimeSignal . TurnFailed ) ;
19591954 await TrySaveThreadAsync ( thread ) ;
1960- if ( saveCurrentSession && session is not null )
1961- await TrySaveSessionAsync ( agent , session , threadId ) ;
1962- else if ( optimizedSessionChanged && session is not null )
1963- await TrySaveSessionAsync ( agent , session , threadId ) ;
1964- else
1965- await TryRebuildAndSaveSessionAsync ( agent , threadId ) ;
1955+ if ( session is not null )
1956+ {
1957+ if ( TryAppendFailedTurnTailToSession ( session , turn ) &&
1958+ await TrySaveSessionAsync ( agent , session , threadId ) )
1959+ {
1960+ return ;
1961+ }
1962+ }
1963+ else if ( persistence . SessionFileExists ( threadId ) )
1964+ {
1965+ return ;
1966+ }
1967+
1968+ await TryRebuildAndSaveSessionAsync ( agent , threadId ) ;
19661969 }
19671970
19681971 try
@@ -2763,7 +2766,6 @@ await AccountGoalUsageAsync(
27632766 // already gone), but the compacted history lets the user re-send their
27642767 // prompt and succeed without any manual cleanup.
27652768 var reactiveMessage = ex . Message ;
2766- var reactiveCompactionSucceeded = false ;
27672769 if ( CompactionErrors . IsPromptTooLong ( ex ) && session is not null )
27682770 {
27692771 try
@@ -2778,7 +2780,6 @@ await AccountGoalUsageAsync(
27782780 {
27792781 tokenTracker ? . Reset ( ) ;
27802782 InvalidatePromptRequestSnapshot ( threadId , "reactive_compaction" ) ;
2781- optimizedSessionChanged = true ;
27822783 var contextUsage = await SaveContextUsageSnapshotAsync (
27832784 threadId ,
27842785 status . ThresholdAfter . Tokens ,
@@ -2807,7 +2808,6 @@ await AccountGoalUsageAsync(
28072808 reactiveMessage =
28082809 "The request exceeded the model's context window. "
28092810 + "History has been compacted; please re-send the message." ;
2810- reactiveCompactionSucceeded = true ;
28112811 }
28122812 else
28132813 {
@@ -2829,8 +2829,7 @@ await AccountGoalUsageAsync(
28292829
28302830 await FailAndPersistTurnAsync (
28312831 reactiveMessage ,
2832- "agent_error" ,
2833- saveCurrentSession : reactiveCompactionSucceeded ) ;
2832+ "agent_error" ) ;
28342833 }
28352834 finally
28362835 {
@@ -4395,6 +4394,143 @@ private async Task TrySaveThreadAsync(SessionThread thread)
43954394 }
43964395 }
43974396
4397+ private static bool TryAppendFailedTurnTailToSession ( AgentSession session , SessionTurn turn )
4398+ {
4399+ if ( ! session . TryGetInMemoryChatHistory (
4400+ out var history ,
4401+ jsonSerializerOptions : SessionPersistenceJsonOptions . Default ) )
4402+ {
4403+ return false ;
4404+ }
4405+
4406+ var turnTail = ThreadStore . BuildModelVisibleHistoryFromTurn ( turn ) ;
4407+ if ( turnTail . Count == 0 )
4408+ return true ;
4409+
4410+ var overlap = FindHistoryTailOverlap ( history , turnTail ) ;
4411+ if ( overlap >= turnTail . Count )
4412+ return true ;
4413+
4414+ var merged = new List < ChatMessage > ( history . Count + turnTail . Count - overlap ) ;
4415+ merged . AddRange ( history ) ;
4416+ for ( var i = overlap ; i < turnTail . Count ; i ++ )
4417+ merged . Add ( turnTail [ i ] ) ;
4418+
4419+ session . SetInMemoryChatHistory ( merged , jsonSerializerOptions : SessionPersistenceJsonOptions . Default ) ;
4420+ return true ;
4421+ }
4422+
4423+ private static int FindHistoryTailOverlap (
4424+ IReadOnlyList < ChatMessage > history ,
4425+ IReadOnlyList < ChatMessage > tail )
4426+ {
4427+ var max = Math . Min ( history . Count , tail . Count ) ;
4428+ for ( var length = max ; length > 0 ; length -- )
4429+ {
4430+ var historyStart = history . Count - length ;
4431+ var matched = true ;
4432+ for ( var i = 0 ; i < length ; i ++ )
4433+ {
4434+ if ( ChatMessagesEquivalent ( history [ historyStart + i ] , tail [ i ] ) )
4435+ continue ;
4436+
4437+ matched = false ;
4438+ break ;
4439+ }
4440+
4441+ if ( matched )
4442+ return length ;
4443+ }
4444+
4445+ return 0 ;
4446+ }
4447+
4448+ private static bool ChatMessagesEquivalent ( ChatMessage left , ChatMessage right )
4449+ {
4450+ if ( left . Role != right . Role )
4451+ return false ;
4452+
4453+ var leftContents = BuildContentSignatures ( left ) ;
4454+ var rightContents = BuildContentSignatures ( right ) ;
4455+ return leftContents . SequenceEqual ( rightContents , StringComparer . Ordinal ) ;
4456+ }
4457+
4458+ private static List < string > BuildContentSignatures ( ChatMessage message )
4459+ {
4460+ var signatures = new List < string > ( ) ;
4461+ foreach ( var content in message . Contents )
4462+ {
4463+ switch ( content )
4464+ {
4465+ case TextContent text :
4466+ {
4467+ var normalized = NormalizeTextForHistorySignature ( text . Text ) ;
4468+ if ( ! string . IsNullOrWhiteSpace ( normalized ) )
4469+ signatures . Add ( "text:" + normalized ) ;
4470+ break ;
4471+ }
4472+ case TextReasoningContent reasoning :
4473+ {
4474+ if ( ReasoningContentHelper . TryGetText ( reasoning , out var reasoningText ) &&
4475+ ! string . IsNullOrWhiteSpace ( reasoningText ) )
4476+ {
4477+ signatures . Add ( "reasoning:" + reasoningText . Trim ( ) ) ;
4478+ }
4479+
4480+ break ;
4481+ }
4482+ case FunctionCallContent call :
4483+ signatures . Add ( $ "call:{ call . CallId } :{ call . Name } ") ;
4484+ break ;
4485+ case FunctionResultContent result :
4486+ signatures . Add (
4487+ $ "result:{ result . CallId } :{ ImageContentSanitizingChatClient . DescribeResult ( result . Result ) } ") ;
4488+ break ;
4489+ default :
4490+ signatures . Add ( $ "{ content . GetType ( ) . FullName } :{ content } ") ;
4491+ break ;
4492+ }
4493+ }
4494+
4495+ return signatures ;
4496+ }
4497+
4498+ private static string NormalizeTextForHistorySignature ( string ? text )
4499+ {
4500+ var normalized = StripSystemReminderBlocks ( text ) . Trim ( ) ;
4501+ var runtimeContextIndex = normalized . IndexOf ( "\n [Runtime Context]" , StringComparison . Ordinal ) ;
4502+ if ( runtimeContextIndex >= 0 )
4503+ normalized = normalized [ ..runtimeContextIndex ] . Trim ( ) ;
4504+ return normalized ;
4505+ }
4506+
4507+ private static string StripSystemReminderBlocks ( string ? text )
4508+ {
4509+ if ( string . IsNullOrEmpty ( text ) )
4510+ return string . Empty ;
4511+
4512+ const string startTag = "<system-reminder>" ;
4513+ const string endTag = "</system-reminder>" ;
4514+
4515+ var result = text ;
4516+ var searchStart = 0 ;
4517+ while ( searchStart < result . Length )
4518+ {
4519+ var start = result . IndexOf ( startTag , searchStart , StringComparison . Ordinal ) ;
4520+ if ( start < 0 )
4521+ break ;
4522+
4523+ var end = result . IndexOf ( endTag , start + startTag . Length , StringComparison . Ordinal ) ;
4524+ var removeLength = end < 0
4525+ ? result . Length - start
4526+ : end + endTag . Length - start ;
4527+ result = result . Remove ( start , removeLength ) ;
4528+ searchStart = start ;
4529+ }
4530+
4531+ return result ;
4532+ }
4533+
43984534 private async Task < bool > TrySaveSessionAsync ( AIAgent agent , AgentSession session , string threadId )
43994535 {
44004536 if ( IsPendingPermanentDeletion ( threadId ) )
0 commit comments