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;