@@ -566,10 +566,17 @@ public async Task ToolCallThenText_TextInsertsAfterToolCallMessage()
566566
567567 history . Add ( TextMessage ( ChatRole . Assistant , "done" ) ) ;
568568
569- var op = Assert . Single ( sink . ContentOperations ) ;
569+ // Two ops: the insert After, plus the #1222 header-suppression Replace on the new element
570+ // (same role as the predecessor assistant tool-call group).
571+ Assert . Equal ( 2 , sink . ContentOperations . Count ) ;
572+ var op = sink . ContentOperations [ 0 ] ;
570573 Assert . Equal ( ChatOutputUpdateLocation . After , op . Location ) ;
571574 Assert . Equal ( ChatOutputHtmlRenderer . MessageId ( 0 ) , op . Path ) ;
572575 Assert . Contains ( ">done<" , op . Content ) ;
576+ var suppression = sink . ContentOperations [ 1 ] ;
577+ Assert . Equal ( ChatOutputUpdateLocation . Replace , suppression . Location ) ;
578+ Assert . Equal ( ChatOutputHtmlRenderer . HeaderId ( ChatOutputHtmlRenderer . MessageId ( 1 ) ) , suppression . Path ) ;
579+ Assert . Contains ( "chat-header-suppressed" , suppression . Content ) ;
573580 }
574581
575582 [ AvaloniaFact ( Timeout = 15_000 ) ]
@@ -1568,10 +1575,15 @@ public void LiveTransformer_ItemAfterHistoryLoad_InsertsAfterLastTopLevelHistory
15681575
15691576 // The last top-level element is the group wrapping items 1-2, so the live item inserts
15701577 // after the group element, not after a nested member.
1571- var op = Assert . Single ( sink . ContentOperations ) ;
1578+ // Two ops: the insert After, plus the #1222 header-suppression Replace on the new element
1579+ // (same assistant role as the tool-call group predecessor).
1580+ Assert . Equal ( 2 , sink . ContentOperations . Count ) ;
1581+ var op = sink . ContentOperations [ 0 ] ;
15721582 Assert . Equal ( ChatOutputUpdateLocation . After , op . Location ) ;
15731583 Assert . Equal ( ChatOutputHtmlRenderer . ToolGroupId ( 1 ) , op . Path ) ;
15741584 Assert . Contains ( "new-live" , op . Content ) ;
1585+ Assert . Equal ( ChatOutputUpdateLocation . Replace , sink . ContentOperations [ 1 ] . Location ) ;
1586+ Assert . Contains ( "chat-header-suppressed" , sink . ContentOperations [ 1 ] . Content ) ;
15751587 }
15761588
15771589 [ Fact ]
@@ -2238,10 +2250,14 @@ public async Task RunningItem_InnerTransformer_SecondMessage_InsertsAfterFirstRu
22382250 runningItem . Items . Add ( TextMessage ( ChatRole . Assistant , "second" ) ) ;
22392251
22402252 var runId = ChatOutputHtmlRenderer . RunningItemId ( 0 ) ;
2241- var op = Assert . Single ( sink . ContentOperations ) ;
2253+ // Two ops: the insert After, plus the #1222 header-suppression Replace on the new element.
2254+ Assert . Equal ( 2 , sink . ContentOperations . Count ) ;
2255+ var op = sink . ContentOperations [ 0 ] ;
22422256 Assert . Equal ( ChatOutputUpdateLocation . After , op . Location ) ;
22432257 Assert . Equal ( ChatOutputHtmlRenderer . RunningMessageId ( runId , 0 ) , op . Path ) ;
22442258 Assert . Contains ( ">second<" , op . Content ) ;
2259+ Assert . Equal ( ChatOutputUpdateLocation . Replace , sink . ContentOperations [ 1 ] . Location ) ;
2260+ Assert . Contains ( "chat-header-suppressed" , sink . ContentOperations [ 1 ] . Content ) ;
22452261 }
22462262
22472263 [ AvaloniaFact ( Timeout = 15_000 ) ]
@@ -2748,10 +2764,15 @@ public async Task UserMessage_AddedToHistory_AnchorsAfterLastTopLevelHistoryElem
27482764
27492765 history . Add ( TextMessage ( ChatRole . User , "latest question" ) ) ;
27502766
2751- var op = Assert . Single ( sink . ContentOperations ) ;
2767+ // Two ops: the insert After, plus the #1222 header-suppression Replace on the new element
2768+ // (same user role as the predecessor history-2).
2769+ Assert . Equal ( 2 , sink . ContentOperations . Count ) ;
2770+ var op = sink . ContentOperations [ 0 ] ;
27522771 Assert . Equal ( ChatOutputUpdateLocation . After , op . Location ) ;
27532772 Assert . Equal ( ChatOutputHtmlRenderer . MessageId ( 2 ) , op . Path ) ;
27542773 Assert . Contains ( "latest question" , op . Content ) ;
2774+ Assert . Equal ( ChatOutputUpdateLocation . Replace , sink . ContentOperations [ 1 ] . Location ) ;
2775+ Assert . Contains ( "chat-header-suppressed" , sink . ContentOperations [ 1 ] . Content ) ;
27552776 }
27562777
27572778 [ Fact ]
@@ -3386,4 +3407,131 @@ public void GenerateHistoryChunk_ConsecutiveToolCalls_EmitsSingleMessageFrameWit
33863407 var groupId = ChatOutputHtmlRenderer . ToolGroupId ( 1 ) ;
33873408 Assert . Contains ( $ "id=\" { groupId } \" ", html ) ;
33883409 }
3410+
3411+ // -----------------------------------------------------------------------------------------
3412+ // #1222 — consecutive same-role items collapse under a single role header.
3413+ // -----------------------------------------------------------------------------------------
3414+
3415+ [ Fact ]
3416+ public void BuildHistoryRenderPlan_WhenConsecutiveAssistantItems_OnlyFirstEmitsVisibleRoleHeader ( )
3417+ {
3418+ var snapshot = new List < AgentChatHistoryItem >
3419+ {
3420+ TextMessage ( ChatRole . Assistant , "one" ) ,
3421+ TextMessage ( ChatRole . Assistant , "two" ) ,
3422+ TextMessage ( ChatRole . Assistant , "three" ) ,
3423+ } ;
3424+ var sink = new RecordingSink ( ) ;
3425+ var plan = BuildPlan ( snapshot , sink ) ;
3426+
3427+ var html = ChatOutputHtmlModel . GenerateHistoryChunk ( plan , 0 , snapshot . Count ) ;
3428+
3429+ // Exactly one visible role header, plus two suppressed placeholders (hidden, no <span>).
3430+ var visibleHeaders = System . Text . RegularExpressions . Regex . Matches ( html , "<div class=\" chat-header\" " ) . Count ;
3431+ var suppressedHeaders = System . Text . RegularExpressions . Regex . Matches ( html , "chat-header-suppressed" ) . Count ;
3432+ Assert . Equal ( 1 , visibleHeaders ) ;
3433+ Assert . Equal ( 2 , suppressedHeaders ) ;
3434+ }
3435+
3436+ [ Fact ]
3437+ public void BuildHistoryRenderPlan_WhenRolesAlternate_EachItemEmitsItsOwnRoleHeader ( )
3438+ {
3439+ var snapshot = new List < AgentChatHistoryItem >
3440+ {
3441+ TextMessage ( ChatRole . User , "u1" ) ,
3442+ TextMessage ( ChatRole . Assistant , "a1" ) ,
3443+ TextMessage ( ChatRole . User , "u2" ) ,
3444+ } ;
3445+ var sink = new RecordingSink ( ) ;
3446+ var plan = BuildPlan ( snapshot , sink ) ;
3447+
3448+ var html = ChatOutputHtmlModel . GenerateHistoryChunk ( plan , 0 , snapshot . Count ) ;
3449+
3450+ Assert . Equal ( 3 , System . Text . RegularExpressions . Regex . Matches ( html , "<div class=\" chat-header\" " ) . Count ) ;
3451+ Assert . DoesNotContain ( "chat-header-suppressed" , html ) ;
3452+ }
3453+
3454+ [ Fact ]
3455+ public void BuildHistoryRenderPlan_WhenConsecutiveToolCallGroupsAndTextAllAssistant_OneAssistantHeaderWrapsRun ( )
3456+ {
3457+ // Screenshot scenario: user question then several tool-call-only assistant messages then
3458+ // an assistant text summary. The run of assistant items must show one assistant header.
3459+ var snapshot = new List < AgentChatHistoryItem >
3460+ {
3461+ TextMessage ( ChatRole . User , "please investigate" ) ,
3462+ ToolCallMessage ( "read_file" , "c1" ) ,
3463+ TextMessage ( ChatRole . Assistant , "summary" ) ,
3464+ } ;
3465+ var sink = new RecordingSink ( ) ;
3466+ var plan = BuildPlan ( snapshot , sink ) ;
3467+
3468+ var html = ChatOutputHtmlModel . GenerateHistoryChunk ( plan , 0 , snapshot . Count ) ;
3469+
3470+ // One user header + one assistant header + one suppressed assistant header (for "summary").
3471+ var userHeader = System . Text . RegularExpressions . Regex . Matches ( html , "<span>user</span>" ) . Count ;
3472+ var assistantHeader = System . Text . RegularExpressions . Regex . Matches ( html , "<span>assistant</span>" ) . Count ;
3473+ Assert . Equal ( 1 , userHeader ) ;
3474+ Assert . Equal ( 1 , assistantHeader ) ;
3475+ Assert . Contains ( "chat-header-suppressed" , html ) ;
3476+ }
3477+
3478+ [ AvaloniaFact ( Timeout = 15_000 ) ]
3479+ public async Task LiveInsert_WhenPredecessorSameRole_EmitsHeaderSuppressionOnNewSlot ( )
3480+ {
3481+ var history = new ObservableCollection < AgentChatHistoryItem >
3482+ {
3483+ TextMessage ( ChatRole . Assistant , "first" ) ,
3484+ } ;
3485+ var sink = new RecordingSink ( ) ;
3486+ using var model = new ChatOutputHtmlModel ( history , new ObservableCollection < AgentChatRunningItem > ( ) , ( ) => true , sink ) ;
3487+ await model . HistoryLoaded ;
3488+ sink . Clear ( ) ;
3489+
3490+ history . Add ( TextMessage ( ChatRole . Assistant , "second" ) ) ;
3491+
3492+ // The reconciliation pass fires a Replace op on the new element's -header placeholder.
3493+ var suppression = sink . ContentOperations
3494+ . SingleOrDefault ( op => op . Location == ChatOutputUpdateLocation . Replace
3495+ && op . Path == ChatOutputHtmlRenderer . HeaderId ( ChatOutputHtmlRenderer . MessageId ( 1 ) ) ) ;
3496+ Assert . NotNull ( suppression ) ;
3497+ Assert . Contains ( "chat-header-suppressed" , suppression ! . Content ) ;
3498+ }
3499+
3500+ [ AvaloniaFact ( Timeout = 15_000 ) ]
3501+ public async Task LiveInsert_WhenPredecessorDifferentRole_DoesNotEmitHeaderSuppression ( )
3502+ {
3503+ var history = new ObservableCollection < AgentChatHistoryItem >
3504+ {
3505+ TextMessage ( ChatRole . Assistant , "first" ) ,
3506+ } ;
3507+ var sink = new RecordingSink ( ) ;
3508+ using var model = new ChatOutputHtmlModel ( history , new ObservableCollection < AgentChatRunningItem > ( ) , ( ) => true , sink ) ;
3509+ await model . HistoryLoaded ;
3510+ sink . Clear ( ) ;
3511+
3512+ history . Add ( TextMessage ( ChatRole . User , "second" ) ) ;
3513+
3514+ Assert . DoesNotContain ( sink . ContentOperations , op =>
3515+ op . Location == ChatOutputUpdateLocation . Replace && op . Content . Contains ( "chat-header-suppressed" ) ) ;
3516+ }
3517+
3518+ [ Fact ]
3519+ public void RenderHeader_WhenSuppressedTrue_EmitsHiddenPlaceholderWithStableId ( )
3520+ {
3521+ var html = ChatOutputHtmlRenderer . RenderHeader ( "msg-1" , "assistant" , timestamp : null , suppressed : true ) ;
3522+
3523+ Assert . Contains ( "id=\" msg-1-header\" " , html ) ;
3524+ Assert . Contains ( "chat-header-suppressed" , html ) ;
3525+ Assert . Contains ( "hidden" , html ) ;
3526+ Assert . DoesNotContain ( "<span>assistant</span>" , html ) ;
3527+ }
3528+
3529+ [ Fact ]
3530+ public void RenderHeader_WhenSuppressedFalse_EmitsFullHeaderAsBefore ( )
3531+ {
3532+ var html = ChatOutputHtmlRenderer . RenderHeader ( "msg-1" , "assistant" ) ;
3533+
3534+ Assert . Contains ( "<span>assistant</span>" , html ) ;
3535+ Assert . DoesNotContain ( "chat-header-suppressed" , html ) ;
3536+ }
33893537}
0 commit comments