fix(webui): split merge-into-tail compaction so reply renders as its own bubble (#29824)

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.
This commit is contained in:
xxxigm
2026-05-21 21:16:43 +07:00
committed by Teknium
parent 691ff7c188
commit 2fef3e2df2

View File

@@ -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
// + <original assistant reply>``. 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 (
<>
<MessageBubble
msg={{ ...msg, content: compactionSplit.summary }}
highlight={highlight}
/>
<MessageBubble
msg={{
...msg,
content: compactionSplit.remainder,
// The remainder is the original assistant reply that the
// compressor pre-pended the summary to — render with the
// normal assistant styling, NOT the muted handoff style.
// ``isCompactionMessage`` returns false on this stripped
// content because it no longer starts with the prefix.
}}
highlight={highlight}
/>
</>
);
}
const isCompaction = compactionSplit !== null;
const style = isCompaction
? ROLE_STYLES.compaction
: ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;