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:
parent
691ff7c188
commit
2fef3e2df2
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user