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 // detect them here and downgrade them to a muted, clearly-labelled
// "Context handoff" row. // "Context handoff" row.
// //
// Keep these prefixes in sync with ``SUMMARY_PREFIX`` and // Keep these prefixes (and the END marker below) in sync with
// ``LEGACY_SUMMARY_PREFIX`` in ``agent/context_compressor.py``. // ``SUMMARY_PREFIX`` / ``LEGACY_SUMMARY_PREFIX`` and the
// merge-into-tail marker in ``agent/context_compressor.py``.
const COMPACTION_PREFIXES = [ const COMPACTION_PREFIXES = [
"[CONTEXT COMPACTION — REFERENCE ONLY]", "[CONTEXT COMPACTION — REFERENCE ONLY]",
"[CONTEXT COMPACTION - REFERENCE ONLY]", "[CONTEXT COMPACTION - REFERENCE ONLY]",
"[CONTEXT SUMMARY]:", "[CONTEXT SUMMARY]:",
] as const; ] as const;
function isCompactionMessage(msg: SessionMessage): boolean { // Marker the compressor inserts between a merged summary and the
if (msg.role !== "user" && msg.role !== "assistant") return false; // original tail message content. When the summary role would collide
const content = msg.content; // with both head and tail roles (e.g. head ends with ``user`` and tail
if (typeof content !== "string") return false; // starts with ``assistant``), the compressor merges the summary as a
const head = content.trimStart(); // prefix on the first tail message instead of inserting a standalone
return COMPACTION_PREFIXES.some((p) => head.startsWith(p)); // 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({ function MessageBubble({
msg, msg,
highlight, 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 const style = isCompaction
? ROLE_STYLES.compaction ? ROLE_STYLES.compaction
: ROLE_STYLES[msg.role] ?? ROLE_STYLES.system; : ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;