fix(compressor): keep last visible assistant reply out of compaction summary + label handoffs in WebUI (#29824)

Two-pronged fix for the WebUI "context compaction block in place of
last assistant response" regression.

Agent layer (the real fix). ``_find_tail_cut_by_tokens`` already had
``_ensure_last_user_message_in_tail`` to keep the most recent user
request out of the compressed middle (#10896), but no symmetric
anchor for the assistant side. When the conversation has an
oversized recent tool result or a long stretch of tool-call/result
pairs *after* the assistant's last visible reply, the token-budget
walk can stop with the previously-visible reply on the wrong side
of ``cut_idx``. The summariser then rolls it into the single
``[CONTEXT COMPACTION — REFERENCE ONLY]`` block persisted as
``role="user"`` or ``role="assistant"``, and from the operator's
perspective the WebUI session viewer
(``web/src/pages/SessionsPage.tsx``) and the TUI chat panel both
suddenly show the opaque "Context compaction" block in the slot
where they were just reading the actual answer:

    User:  "i cant see the output of the last message you sent,
            i did see it previously, however now see 'context
            compaction'"

Added ``_ensure_last_assistant_message_in_tail`` mirror of the
user-side anchor. It looks for the most recent assistant message
with non-empty text content (skipping tool-call-only assistant
"stubs" which the UI renders as small "calling tool X" indicators
rather than a readable bubble) and walks ``cut_idx`` back through
the standard ``_align_boundary_backward`` so we don't split a
tool_call/result group that immediately precedes it. The two
anchors are chained — each only walks ``cut_idx`` backward, so
the tail can only grow.

Falls back to "most recent assistant of any kind" only when no
content-bearing reply exists in the compressible region (fresh
multi-step tool sequence with no prior reply) — in that case the
agent-side fix is effectively a no-op and the existing
user-message anchor carries the load.

WebUI layer (clarity). Added ``isCompactionMessage`` detector that
recognises the ``[CONTEXT COMPACTION — REFERENCE ONLY]`` (current)
and ``[CONTEXT SUMMARY]:`` (legacy) prefixes from
``agent/context_compressor.py``, and a new ``compaction`` entry
in ``MessageBubble``'s ``ROLE_STYLES`` map. Compaction blocks
now render as muted, italicised system-style rows labelled
``Context handoff`` — clearly metadata, not the assistant's
actual reply — so an operator scrolling back through a long
session can't mistake the summary for a real answer.

Keeping the detected prefixes inline (rather than importing them)
because the WebUI bundle has no Python interop. A guardrail comment
points readers at the source-of-truth constants in
``agent/context_compressor.py``.
This commit is contained in:
xxxigm 2026-05-21 20:59:18 +07:00 committed by Teknium
parent 7a318aae22
commit 691ff7c188
2 changed files with 149 additions and 4 deletions

View file

@ -147,6 +147,32 @@ function ToolCallBlock({
);
}
// Context-compaction handoff blocks are persisted as ``role="user"`` or
// ``role="assistant"`` with content starting with one of these prefixes —
// they're metadata inserted by ``agent/context_compressor.py``, NOT real
// turns the user typed or the model replied with. Rendering them with
// the same styling as regular messages confuses operators scrolling the
// session timeline (#29824 — "WebUI can show context compaction block
// instead of latest assistant response after compression"), so we
// 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``.
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));
}
function MessageBubble({
msg,
highlight,
@ -180,12 +206,25 @@ function MessageBubble({
text: "text-warning",
label: t.sessions.roles.tool,
},
// Compaction handoffs render as faded system-style metadata with a
// distinctive label so they can't be mistaken for real assistant
// replies during a scroll-back review (#29824).
compaction: {
bg: "bg-muted/50",
text: "text-muted-foreground italic",
label: "Context handoff",
},
};
const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
const label = msg.tool_name
? `${t.sessions.roles.tool}: ${msg.tool_name}`
: style.label;
const isCompaction = isCompactionMessage(msg);
const style = isCompaction
? ROLE_STYLES.compaction
: ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
const label = isCompaction
? ROLE_STYLES.compaction.label
: msg.tool_name
? `${t.sessions.roles.tool}: ${msg.tool_name}`
: style.label;
// Check if any search term appears as a prefix of any word in content
const isHit = (() => {