Skip to content

Commit 1da2f8d

Browse files
Fix #1222: collapse consecutive same-role chat items under a single role header
Adds a SuppressRoleHeader flag on ChatMessageHtmlModel and ToolCallGroupHtmlModel and a ReconcileRoleHeaders pass on ChatMessageHtmlTransformer that runs after every OnInsert/OnRemoveAt/OnMove/Update/RebuildForReplace/RepairFailedElement. The pass walks the target list once and folds consecutive DOM-owning slots with the same effective role into a role run: the leader keeps its <div class=chat-header>; each subsequent same-role slot gets its header replaced by a hidden placeholder that preserves the stable id so future flips can restore it. Group-owned frames use role assistant. The tool role is transparent (never breaks a surrounding run). BuildHistoryRenderPlan gains a matching Pass 4 so preloaded chunks are generated off-thread with the same invariant, and RenderMessage/RenderToolCallGroup grow a suppressRoleHeader parameter that BuildHtml threads through. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 136fbc9 commit 1da2f8d

3 files changed

Lines changed: 345 additions & 12 deletions

File tree

Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlModelTests.cs

Lines changed: 152 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)