From 2fef3e2df2ce35a4b4e1a452b821dc9ebca2d3aa Mon Sep 17 00:00:00 2001 From: xxxigm Date: Thu, 21 May 2026 21:16:43 +0700 Subject: [PATCH] fix(webui): split merge-into-tail compaction so reply renders as its own bubble (#29824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compressor has a "double-collision" fallback path: when the chosen ``summary_role`` collides with the first tail message AND the flipped role would collide with the last head message, it can't emit a standalone summary turn (consecutive same-role messages break Anthropic and friends). It instead prepends the summary + end-of-summary marker to the first tail message's content via ``_merge_summary_into_tail``. With the matching anchor from the previous commit, that first tail message is now usually the user's previously-visible assistant reply — so the persisted assistant turn ends up shaped as ``[CONTEXT COMPACTION ...] ... --- END OF CONTEXT SUMMARY --- ... THE ACTUAL REPLY``. Without splitting it, the session viewer renders one big "Context handoff" bubble and the reply text is buried inside the metadata blob — which is exactly the "can't see the last reply" experience #29824 reports, just one layer deeper. Added ``splitCompactionContent`` that detects the merge marker (kept in sync with ``--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---`` in ``agent/context_compressor.py``) and ``MessageBubble`` now recurses on the two halves: the prefix half renders as the muted "Context handoff" row, the remainder half renders with the original assistant styling. Pure (non-merged) summary messages hit the no-remainder branch and still render as a single "Context handoff" row, preserving the original behaviour. --- web/src/pages/SessionsPage.tsx | 81 ++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 1701f80f82d..c48d2453876 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -157,22 +157,50 @@ function ToolCallBlock({ // detect them here and downgrade them to a muted, clearly-labelled // "Context handoff" row. // -// Keep these prefixes in sync with ``SUMMARY_PREFIX`` and -// ``LEGACY_SUMMARY_PREFIX`` in ``agent/context_compressor.py``. +// Keep these prefixes (and the END marker below) in sync with +// ``SUMMARY_PREFIX`` / ``LEGACY_SUMMARY_PREFIX`` and the +// merge-into-tail marker in ``agent/context_compressor.py``. const COMPACTION_PREFIXES = [ "[CONTEXT COMPACTION — REFERENCE ONLY]", "[CONTEXT COMPACTION - REFERENCE ONLY]", "[CONTEXT SUMMARY]:", ] as const; -function isCompactionMessage(msg: SessionMessage): boolean { - if (msg.role !== "user" && msg.role !== "assistant") return false; - const content = msg.content; - if (typeof content !== "string") return false; - const head = content.trimStart(); - return COMPACTION_PREFIXES.some((p) => head.startsWith(p)); +// Marker the compressor inserts between a merged summary and the +// original tail message content. When the summary role would collide +// with both head and tail roles (e.g. head ends with ``user`` and tail +// starts with ``assistant``), the compressor merges the summary as a +// prefix on the first tail message instead of inserting a standalone +// row. We split on this marker so the WebUI still shows the original +// assistant reply as its own readable bubble — otherwise the merged +// row reads as a single opaque "Context compaction" block and the +// user can't see the reply (#29824). +const COMPACTION_END_MARKER = + "--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---"; + +interface CompactionSplit { + /** Summary text (header + body, without the end marker). */ + summary: string; + /** Original message content that came after the end marker. */ + remainder: string; } +function splitCompactionContent(content: string): CompactionSplit | null { + const head = content.trimStart(); + if (!COMPACTION_PREFIXES.some((p) => head.startsWith(p))) return null; + const markerIdx = content.indexOf(COMPACTION_END_MARKER); + if (markerIdx < 0) { + return { summary: content, remainder: "" }; + } + return { + summary: content.slice(0, markerIdx), + remainder: content + .slice(markerIdx + COMPACTION_END_MARKER.length) + .replace(/^\s+/, ""), + }; +} + + function MessageBubble({ msg, highlight, @@ -216,7 +244,42 @@ function MessageBubble({ }, }; - const isCompaction = isCompactionMessage(msg); + // When a compaction handoff is merged into the front of the first + // tail message (the compressor's double-collision path — + // ``_merge_summary_into_tail`` in ``agent/context_compressor.py``), + // the message we received is ``[CONTEXT COMPACTION ...] + END_MARKER + // + ``. We split it back into two visual + // rows here so the operator's actual answer survives as a readable + // bubble next to the (clearly-labelled) handoff metadata (#29824). + const compactionSplit = + typeof msg.content === "string" + ? splitCompactionContent(msg.content) + : null; + + if (compactionSplit && compactionSplit.remainder) { + return ( + <> + + + + ); + } + + const isCompaction = compactionSplit !== null; const style = isCompaction ? ROLE_STYLES.compaction : ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;