diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py
index 742af1453807..f144b3f67a63 100644
--- a/agent/agent_runtime_helpers.py
+++ b/agent/agent_runtime_helpers.py
@@ -445,6 +445,45 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
return repairs
+def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int:
+ """Run :func:`repair_message_sequence` and keep the SessionDB flush
+ cursor consistent with the compacted list (#44837).
+
+ ``repair_message_sequence`` merges/drops messages in place, shrinking
+ the list. ``_last_flushed_db_idx`` (the DB-write cursor) indexes into
+ that list, so after compaction it can point past the new end — the
+ turn-end flush would then skip the assistant/tool chain entirely — or
+ past unflushed messages shifted to lower indexes.
+
+ Repair preserves object identity for surviving messages, so counting
+ the survivors from the previously-flushed prefix gives the exact new
+ cursor even when messages are dropped/merged at indexes *before* the
+ cursor — a plain ``min()`` clamp would silently skip that many
+ unflushed rows. Falls back to the clamp when no prefix snapshot is
+ available.
+
+ Returns the number of repairs made (same as ``repair_message_sequence``).
+ """
+ pre_repair_flushed_ids = None
+ flush_cursor = getattr(agent, "_last_flushed_db_idx", None)
+ if isinstance(flush_cursor, int) and flush_cursor > 0:
+ pre_repair_flushed_ids = {id(m) for m in messages[:flush_cursor]}
+
+ repairs = repair_message_sequence(agent, messages)
+
+ if repairs > 0 and hasattr(agent, "_last_flushed_db_idx"):
+ if pre_repair_flushed_ids is not None:
+ agent._last_flushed_db_idx = sum(
+ 1 for m in messages if id(m) in pre_repair_flushed_ids
+ )
+ else:
+ agent._last_flushed_db_idx = min(
+ agent._last_flushed_db_idx, len(messages)
+ )
+
+ return repairs
+
+
def strip_think_blocks(agent, content: str) -> str:
"""Remove reasoning/thinking blocks from content, returning only visible text.
diff --git a/agent/context_compressor.py b/agent/context_compressor.py
index 4611616085f8..16db1bedc30f 100644
--- a/agent/context_compressor.py
+++ b/agent/context_compressor.py
@@ -69,6 +69,31 @@ SUMMARY_PREFIX = (
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
+# Metadata key added to context compression summary messages so that frontends
+# (CLI, Desktop, gateway, TUI) can distinguish them from real assistant/user
+# messages and filter or render them appropriately without content-prefix
+# heuristics. See https://github.com/NousResearch/hermes-agent/issues/38389
+#
+# Underscore-prefixed ON PURPOSE: the wire sanitizers
+# (agent/transports/chat_completions.py convert_messages and the summary-path
+# mirror in agent/chat_completion_helpers.py) strip every top-level message
+# key starting with "_" before the request leaves the process. Strict
+# OpenAI-compatible gateways (Fireworks, Mistral, Moonshot/Kimi, opencode-go)
+# reject payloads carrying unknown keys with "Extra inputs are not permitted",
+# poisoning every subsequent request in the session — a bare key like
+# "is_compressed_summary" would reach the wire and trip exactly that.
+COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
+
+# Appended to every standalone summary message (and to the merged-into-tail
+# prefix) so the model has an unambiguous "summary ends here" boundary.
+# Without it, weak models read the verbatim "## Active Task" quote as fresh
+# user input (#11475, #14521) or regurgitate an assistant-role summary as
+# their own output (#33256).
+_SUMMARY_END_MARKER = (
+ "--- END OF CONTEXT SUMMARY — "
+ "respond to the message below, not the summary above ---"
+)
+
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
@@ -146,6 +171,11 @@ _FALLBACK_TURN_MAX_CHARS = 700
_AUTO_FOCUS_MAX_TURNS = 3
_AUTO_FOCUS_TURN_MAX_CHARS = 260
_AUTO_FOCUS_MAX_CHARS = 700
+# Keep a short run of recent messages verbatim even when the token budget is
+# already exhausted. The public ``protect_last_n`` default is intentionally
+# high for small/light tails, but using all 20 as a hard floor here would bring
+# back the old large-tool-output case where nothing can be compacted.
+_MAX_TAIL_MESSAGE_FLOOR = 8
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
@@ -1616,7 +1646,13 @@ This compaction should PRIORITISE preserving all information related to the focu
text = (summary or "").strip()
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
if text.startswith(prefix):
- return text[len(prefix):].lstrip()
+ text = text[len(prefix):].lstrip()
+ break
+ # Strip the trailing end marker too — a rehydrated handoff body that
+ # keeps it would leak the boundary directive into the iterative-update
+ # summarizer prompt (and the marker is re-appended on insertion anyway).
+ if text.endswith(_SUMMARY_END_MARKER):
+ text = text[: -len(_SUMMARY_END_MARKER)].rstrip()
return text
@classmethod
@@ -1632,6 +1668,19 @@ This compaction should PRIORITISE preserving all information related to the focu
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
+ @staticmethod
+ def _has_compressed_summary_metadata(message: Any) -> bool:
+ """Return True if *message* carries the compressed-summary flag.
+
+ Callers (frontends, CLI, gateway) can use this to distinguish context
+ compaction summaries from real assistant or user messages without
+ relying on content-prefix heuristics. The flag is in-process only —
+ the wire sanitizers strip underscore-prefixed keys before API calls.
+ """
+ if not isinstance(message, dict):
+ return False
+ return bool(message.get(COMPRESSED_SUMMARY_METADATA_KEY))
+
@classmethod
def _derive_auto_focus_topic(
cls,
@@ -1817,6 +1866,105 @@ This compaction should PRIORITISE preserving all information related to the focu
return i
return -1
+ def _find_last_assistant_message_idx(
+ self, messages: List[Dict[str, Any]], head_end: int
+ ) -> int:
+ """Return the index of the last user-visible assistant reply at or
+ after *head_end*, or -1.
+
+ A "user-visible reply" is an assistant message with non-empty
+ textual content — i.e. one that the WebUI / TUI / SessionsPage
+ rendered as a bubble the operator could read. We deliberately
+ skip assistant messages that contain only ``tool_calls`` (and
+ no text), because those render as small "calling tool X"
+ indicators and aren't what the reporter means by "the output
+ of the last message you sent" (#29824).
+
+ Falling back to the most recent assistant message of ANY kind
+ only kicks in when no content-bearing assistant message exists
+ in the compressible region — typically a fresh session that
+ just started a multi-step tool sequence with no prior reply
+ to anchor. In that case the agent fix is a no-op and the
+ existing user-message anchor carries the load.
+ """
+ last_any = -1
+ for i in range(len(messages) - 1, head_end - 1, -1):
+ msg = messages[i]
+ if msg.get("role") != "assistant":
+ continue
+ if last_any < 0:
+ last_any = i
+ content = msg.get("content")
+ if isinstance(content, str) and content.strip():
+ return i
+ if isinstance(content, list):
+ # Multimodal / Anthropic-style content: look for any
+ # text block with non-empty text.
+ for part in content:
+ if isinstance(part, dict):
+ text = part.get("text") or part.get("content")
+ if isinstance(text, str) and text.strip():
+ return i
+ return last_any
+
+ def _ensure_last_assistant_message_in_tail(
+ self,
+ messages: List[Dict[str, Any]],
+ cut_idx: int,
+ head_end: int,
+ ) -> int:
+ """Guarantee the most recent assistant message is in the protected tail.
+
+ WebUI / TUI / SessionsPage bug (#29824). Without this anchor,
+ ``_find_tail_cut_by_tokens`` can leave the user's most recent
+ visible assistant response inside the compressed middle region —
+ especially when the conversation has a single oversized tool
+ result or a long stretch of tool-call/result pairs after the
+ last assistant reply. The summariser then rolls that reply up
+ into the single ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block
+ persisted as ``role="user"`` or ``role="assistant"``. 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 assistant's actual reply:
+
+ User: "i cant see the output of the last message you
+ sent, i did see it previously, however now see
+ 'context compaction'"
+
+ Mirror of ``_ensure_last_user_message_in_tail`` but anchors on
+ the last assistant-role message. Re-runs the tool-group
+ alignment so we don't split a ``tool_call`` / ``tool_result``
+ group that immediately precedes the anchored message — orphaned
+ tool messages would otherwise be removed by
+ ``_sanitize_tool_pairs`` and trigger the same data-loss symptom
+ we're trying to prevent.
+ """
+ last_asst_idx = self._find_last_assistant_message_idx(messages, head_end)
+ if last_asst_idx < 0:
+ # No assistant message in the compressible region — nothing
+ # to anchor (single-turn pre-reply state, etc.).
+ return cut_idx
+ if last_asst_idx >= cut_idx:
+ # Already in the tail — the token-budget walk did the right
+ # thing on its own.
+ return cut_idx
+ # Pull cut_idx back to the assistant message, then re-align so
+ # we don't split a tool group that immediately precedes it
+ # (e.g. an ``assistant(tool_calls)`` → ``tool(result)`` →
+ # ``assistant(final reply)`` sequence would otherwise leave the
+ # ``tool`` orphan when cut lands at the final reply).
+ new_cut = self._align_boundary_backward(messages, last_asst_idx)
+ if not self.quiet_mode:
+ logger.debug(
+ "Anchoring tail cut to last assistant message at index %d "
+ "(was %d, aligned to %d) to keep the previously-visible "
+ "reply out of the compaction summary (#29824)",
+ last_asst_idx, cut_idx, new_cut,
+ )
+ # Safety: never go back into the head region.
+ return max(new_cut, head_end + 1)
+
def _ensure_last_user_message_in_tail(
self,
messages: List[Dict[str, Any]],
@@ -1875,11 +2023,12 @@ This compaction should PRIORITISE preserving all information related to the focu
derived from ``summary_target_ratio * context_length``, so it
scales automatically with the model's context window.
- Token budget is the primary criterion. A hard minimum of 3 messages
- is always protected, but the budget is allowed to exceed by up to
- 1.5x to avoid cutting inside an oversized message (tool output, file
- read, etc.). If even the minimum 3 messages exceed 1.5x the budget
- the cut is placed right after the head so compression still runs.
+ Token budget is the primary criterion. A bounded message-count floor
+ keeps a short run of recent turns verbatim even when the budget is
+ exhausted, but the budget is allowed to exceed by up to 1.5x to avoid
+ cutting inside an oversized message (tool output, file read, etc.). If
+ even that floor exceeds 1.5x the budget, the cut is placed right after
+ the head so compression still runs.
Never cuts inside a tool_call/result group. Always ensures the most
recent user message is in the tail (see ``_ensure_last_user_message_in_tail``).
@@ -1887,8 +2036,19 @@ This compaction should PRIORITISE preserving all information related to the focu
if token_budget is None:
token_budget = self.tail_token_budget
n = len(messages)
- # Hard minimum: always keep at least 3 messages in the tail
- min_tail = min(3, n - head_end - 1) if n - head_end > 1 else 0
+ # Hard minimum: always keep a bounded recent-message floor in the tail.
+ # ``protect_last_n`` remains a minimum up to the cap; the cap avoids
+ # preserving a whole run of bulky tool outputs on every compaction.
+ available_tail = max(0, n - head_end - 1)
+ min_tail_floor = max(3, min(self.protect_last_n, _MAX_TAIL_MESSAGE_FLOOR))
+ # Leave at least two non-head messages available to summarize on short
+ # transcripts; otherwise compression can replace a tiny middle with a
+ # summary and save no messages at all.
+ compressible_tail_cap = max(3, available_tail - 2)
+ min_tail = (
+ min(min_tail_floor, compressible_tail_cap, available_tail)
+ if available_tail > 1 else 0
+ )
soft_ceiling = int(token_budget * 1.5)
accumulated = 0
cut_idx = n # start from beyond the end
@@ -1960,6 +2120,13 @@ This compaction should PRIORITISE preserving all information related to the focu
# active task is never lost to compression (fixes #10896).
cut_idx = self._ensure_last_user_message_in_tail(messages, cut_idx, head_end)
+ # Ensure the most recent assistant message is always in the tail
+ # so the previously-visible reply isn't silently rolled into the
+ # ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block (fixes #29824).
+ # Each anchor only walks ``cut_idx`` backward, so chaining them is
+ # monotonic — the tail can only grow, never shrink.
+ cut_idx = self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end)
+
return max(cut_idx, head_end + 1)
# ------------------------------------------------------------------
@@ -2193,32 +2360,33 @@ This compaction should PRIORITISE preserving all information related to the focu
# When the summary lands as a standalone role="user" message,
# weak models read the verbatim "## Active Task" quote of a past
- # user request as fresh input (#11475, #14521). Append the explicit
- # end marker — the same one used in the merge-into-tail path — so
- # the model has a clear "summary above, not new input" signal.
- if not _merge_summary_into_tail and summary_role == "user":
- summary = (
- summary
- + "\n\n--- END OF CONTEXT SUMMARY — "
- "respond to the message below, not the summary above ---"
- )
+ # user request as fresh input (#11475, #14521).
+ # When it lands as role="assistant", models may regurgitate the
+ # summary text as their own output (#33256). In both cases, append
+ # the explicit end marker so the model has a clear "summary ends
+ # here, respond to the message below" signal.
+ if not _merge_summary_into_tail:
+ summary = summary + "\n\n" + _SUMMARY_END_MARKER
if not _merge_summary_into_tail:
- compressed.append({"role": summary_role, "content": summary})
+ compressed.append({
+ "role": summary_role,
+ "content": summary,
+ COMPRESSED_SUMMARY_METADATA_KEY: True,
+ })
for i in range(compress_end, n_messages):
msg = messages[i].copy()
if _merge_summary_into_tail and i == compress_end:
- merged_prefix = (
- summary
- + "\n\n--- END OF CONTEXT SUMMARY — "
- "respond to the message below, not the summary above ---\n\n"
- )
+ merged_prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n"
msg["content"] = _append_text_to_content(
msg.get("content"),
merged_prefix,
prepend=True,
)
+ # Mark the merged message so frontends can identify it as
+ # containing a compression summary prefix.
+ msg[COMPRESSED_SUMMARY_METADATA_KEY] = True
_merge_summary_into_tail = False
compressed.append(msg)
diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py
index bcd84a373bbe..05d19772d9ca 100644
--- a/agent/conversation_loop.py
+++ b/agent/conversation_loop.py
@@ -595,7 +595,11 @@ def run_conversation(
# landed after an orphan tool result). Most providers return
# empty content on malformed sequences, which would otherwise
# retrigger the empty-retry loop indefinitely.
- repaired_seq = agent._repair_message_sequence(messages)
+ # repair_message_sequence_with_cursor also recomputes the SessionDB
+ # flush cursor (_last_flushed_db_idx) when repair compacts the list,
+ # so the turn-end flush doesn't skip the assistant/tool chain (#44837).
+ from agent.agent_runtime_helpers import repair_message_sequence_with_cursor
+ repaired_seq = repair_message_sequence_with_cursor(agent, messages)
if repaired_seq > 0:
request_logger.info(
"Repaired %s message-alternation violations before request (session=%s)",
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 26b435c7c9e1..725039620f9e 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -53,6 +53,7 @@ import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
+import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
@@ -397,6 +398,7 @@ export function ChatView({
)}
+ {showChatBar && }
diff --git a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx
new file mode 100644
index 000000000000..b5d947ac18cf
--- /dev/null
+++ b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx
@@ -0,0 +1,58 @@
+import { useStore } from '@nanostores/react'
+import { useRef } from 'react'
+
+import { Codicon } from '@/components/ui/codicon'
+import { useI18n } from '@/i18n'
+import { triggerHaptic } from '@/lib/haptics'
+import { cn } from '@/lib/utils'
+import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll'
+
+/**
+ * Floating "jump to bottom" control. Sits centered just above the composer,
+ * clearing the out-of-flow status stack via the same measured-height CSS vars
+ * the thread's bottom clearance uses (`--composer-measured-height` +
+ * `--status-stack-measured-height`), so it never overlaps the queue / subagent
+ * / background cards. Visible only while the user has scrolled meaningfully
+ * away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
+ *
+ * Enter/exit motion lives in styles.css under `.thread-jump-button` — a
+ * directional scale (contract in from 1.1, contract out to 0.9) keyed off
+ * `data-state`. `idle` (never-shown) stays silent so it can't flash on mount;
+ * `in`/`out` only swap once it has actually appeared.
+ */
+export function ScrollToBottomButton() {
+ const { t } = useI18n()
+ const visible = useStore($threadJumpButtonVisible)
+ const hasShownRef = useRef(false)
+
+ if (visible) {
+ hasShownRef.current = true
+ }
+
+ const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
+
+ return (
+
+ )
+}
diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx
index 0b3a5c824afe..c42084138493 100644
--- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx
@@ -14,13 +14,21 @@ import {
import { setMutableRef } from '@/lib/mutable-ref'
import { cn } from '@/lib/utils'
-import { setThreadScrolledUp } from '@/store/thread-scroll'
+import {
+ onScrollToBottomRequest,
+ resetThreadScroll,
+ setThreadJumpButtonVisible,
+ setThreadScrolledUp
+} from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
+// Reveal the floating jump button only once scrolled meaningfully away — above
+// AT_BOTTOM_THRESHOLD so a sub-pixel settle never flashes it.
+const JUMP_BUTTON_THRESHOLD = 10
type ThreadMessageComponents = ComponentProps['components']
@@ -309,7 +317,7 @@ function useThreadScrollAnchor({
})
}, [groupCount, pinToBottom, stickyBottomRef, virtualizer])
- useEffect(() => () => setThreadScrolledUp(false), [])
+ useEffect(() => () => resetThreadScroll(), [])
// Track at-bottom state, dim composer when scrolled up, disarm on user
// scroll/wheel/touch.
@@ -325,6 +333,13 @@ function useThreadScrollAnchor({
programmaticScrollPendingRef.current = 0
}
+ // Dim the composer the instant we leave the bottom; reveal the jump button
+ // only once scrolled meaningfully away.
+ const publishScrollDistance = (dist: number) => {
+ setThreadScrolledUp(dist > AT_BOTTOM_THRESHOLD)
+ setThreadJumpButtonVisible(dist > JUMP_BUTTON_THRESHOLD)
+ }
+
const onScroll = () => {
const top = el.scrollTop
@@ -342,22 +357,19 @@ function useThreadScrollAnchor({
lastClientHeightRef.current = el.clientHeight
// Always re-arm — sticky-bottom should hold through clamp races.
setMutableRef(stickyBottomRef, true)
- const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
- setThreadScrolledUp(!atBottom)
+ publishScrollDistance(el.scrollHeight - (top + el.clientHeight))
return
}
- // Disarm only when `scrollTop` decreases while both content height and
- // viewport height are stable. A bare `top < lastTopRef.current` check is
- // unsafe: virtualizer measurement, streaming markdown, composer resizing,
- // window resizing, and toolbar/status updates can all move scrollTop as a
- // layout side effect. Wheel-up and touchmove still disarm immediately via
- // their own listeners below, so real user intent remains covered.
+ // Disarm on ANY upward movement (even 1px), but only while content +
+ // viewport height are stable — virtualizer measurement, streaming
+ // markdown, and composer/window resize all shift scrollTop as a layout
+ // side effect. Wheel-up and touchmove disarm immediately too (below).
const heightGrew = el.scrollHeight > lastHeightRef.current
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
- if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) {
+ if (!heightGrew && !clientHeightChanged && top < lastTopRef.current) {
setMutableRef(stickyBottomRef, false)
}
@@ -365,13 +377,14 @@ function useThreadScrollAnchor({
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
- const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
+ const distFromBottom = el.scrollHeight - (top + el.clientHeight)
- if (atBottom) {
+ // Re-arm follow only once genuinely back at the bottom.
+ if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
setMutableRef(stickyBottomRef, true)
}
- setThreadScrolledUp(!atBottom)
+ publishScrollDistance(distFromBottom)
}
const onWheel = (event: WheelEvent) => {
@@ -391,15 +404,28 @@ function useThreadScrollAnchor({
}
}, [scrollerRef, stickyBottomRef])
- // Intentionally NO streaming auto-follow. Earlier builds ran a
- // ResizeObserver here that re-pinned the viewport to the bottom on every
- // content growth while a turn was running, so the chat tracked tokens as
- // they streamed. That behavior is removed by request: once a turn is in
- // flight the viewport stays exactly where the user left it. The viewport
- // is still moved to the bottom ONCE per user submit / new turn / session
- // change (see the layout effect and the session-change effect below) so a
- // freshly submitted message lands in view — but it does not chase the
- // stream afterward.
+ // Streaming auto-follow: while — and ONLY while — parked at the bottom, chase
+ // content growth (streaming tokens, late measurement, Shiki re-highlight) so
+ // the tail stays in view. One upward pixel (scroll/wheel/touch above) flips
+ // the gate false and following stops until the user returns to the bottom.
+ // Keyed on the virtualizer's own size signal and pinned in useLayoutEffect —
+ // the virtualizer's scrollToFn runs in the same pre-paint pass, so the two
+ // don't fight (no rubber-banding). pinToBottom no-ops at bottom, so rapid
+ // growth is cheap.
+ const totalSize = virtualizer.getTotalSize()
+ const prevTotalSizeRef = useRef(null)
+ useLayoutEffect(() => {
+ const prev = prevTotalSizeRef.current
+ prevTotalSizeRef.current = totalSize
+
+ if (enabled && prev !== null && totalSize > prev && stickyBottomRef.current) {
+ pinToBottom()
+ }
+ }, [enabled, pinToBottom, stickyBottomRef, totalSize])
+
+ // The floating jump button asks us to return to the bottom; same re-arm + pin
+ // path as a new turn.
+ useEffect(() => onScrollToBottomRequest(jumpToBottom), [jumpToBottom])
// Jump to bottom on session change OR when an empty thread first gets
// content. Both share the same intent and the same effect.
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 4542b0d02f42..3e9671e21979 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -1666,6 +1666,7 @@ export const en: Translations = {
stopReading: 'Stop reading',
readAloud: 'Read aloud',
editMessage: 'Edit message',
+ scrollToBottom: 'Scroll to bottom',
stop: 'Stop',
restorePrevious: 'Restore previous checkpoint',
restoreCheckpoint: 'Restore checkpoint',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 52a9a1692db5..7b75afa57453 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1325,6 +1325,7 @@ export interface Translations {
stopReading: string
readAloud: string
editMessage: string
+ scrollToBottom: string
stop: string
restorePrevious: string
restoreCheckpoint: string
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 67c86c52cba7..9dbe863714af 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1846,6 +1846,7 @@ export const zh: Translations = {
stopReading: '停止朗读',
readAloud: '朗读',
editMessage: '编辑消息',
+ scrollToBottom: '滚动到底部',
stop: '停止',
restorePrevious: '恢复上一个检查点',
restoreCheckpoint: '恢复检查点',
diff --git a/apps/desktop/src/store/thread-scroll.ts b/apps/desktop/src/store/thread-scroll.ts
index b577f50404de..c0a9afd741fd 100644
--- a/apps/desktop/src/store/thread-scroll.ts
+++ b/apps/desktop/src/store/thread-scroll.ts
@@ -1,11 +1,35 @@
-import { atom } from 'nanostores'
+import { atom, type WritableAtom } from 'nanostores'
+// `$threadScrolledUp` flips the instant the viewport leaves the bottom (dims the
+// composer / status stack). `$threadJumpButtonVisible` trips a little further up
+// (~10px) so the floating jump control only shows once meaningfully away.
export const $threadScrolledUp = atom(false)
+export const $threadJumpButtonVisible = atom(false)
-export function setThreadScrolledUp(value: boolean) {
- if ($threadScrolledUp.get() === value) {
- return
+// Skip no-op writes so subscribers don't churn on every scroll tick.
+const setter = (target: WritableAtom) => (value: boolean) => {
+ if (target.get() !== value) {
+ target.set(value)
}
-
- $threadScrolledUp.set(value)
}
+
+export const setThreadScrolledUp = setter($threadScrolledUp)
+export const setThreadJumpButtonVisible = setter($threadJumpButtonVisible)
+
+export const resetThreadScroll = () => {
+ setThreadScrolledUp(false)
+ setThreadJumpButtonVisible(false)
+}
+
+// Cross-component bridge: the jump button lives by the composer, the re-arm +
+// pin machinery lives in the virtualizer. The virtualizer registers a handler;
+// the button fires it. Mirrors the composer focus/insert emitter pattern.
+const handlers = new Set<() => void>()
+
+export const onScrollToBottomRequest = (handler: () => void) => {
+ handlers.add(handler)
+
+ return () => void handlers.delete(handler)
+}
+
+export const requestScrollToBottom = () => handlers.forEach(handler => handler())
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index 31f7c20258c4..ec0665cac7d9 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -1269,6 +1269,62 @@ canvas {
}
}
+/* Floating "jump to bottom" control (see scroll-to-bottom-button.tsx).
+ Directional scale: it contracts toward 1 as it arrives (from 1.1) and keeps
+ contracting to 0.9 as it leaves — always shrinking in the direction of
+ travel, so the motion reads as a soft settle / recede rather than a pop. The
+ X half-offset stays baked into every transform so `left-1/2` centering holds
+ through the animation. */
+.thread-jump-button {
+ opacity: 0;
+ transform: translateX(-50%) translateY(0.3rem) scale(0.9);
+}
+
+.thread-jump-button[data-state='in'] {
+ animation: thread-jump-in 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
+}
+
+.thread-jump-button[data-state='out'] {
+ animation: thread-jump-out 180ms ease-in forwards;
+}
+
+@keyframes thread-jump-in {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(0.3rem) scale(1.1);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+ }
+}
+
+@keyframes thread-jump-out {
+ from {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+ }
+
+ to {
+ opacity: 0;
+ transform: translateX(-50%) translateY(0.3rem) scale(0.9);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .thread-jump-button[data-state='in'],
+ .thread-jump-button[data-state='out'] {
+ animation: none;
+ transition: opacity 120ms linear;
+ }
+
+ .thread-jump-button[data-state='in'] {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+ }
+}
+
@keyframes code-card-stream-glow {
from {
border-color: color-mix(in srgb, var(--dt-ring) 18%, var(--ui-stroke-tertiary));
@@ -1290,4 +1346,3 @@ canvas {
animation: none;
}
}
-
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 257f775e9ed3..f2ad89f7601c 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -8750,6 +8750,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
except Exception:
pass # profiles module not available or no profiles
+ # Backfill per-profile .env files for profiles created before the
+ # .env-seeding fix (#44792). Copies the default install's .env so
+ # those profiles keep the credentials they were effectively using.
+ try:
+ from hermes_cli.profiles import backfill_profile_envs
+
+ backfilled = backfill_profile_envs(quiet=True)
+ if backfilled:
+ print()
+ print(
+ f"→ Seeded .env for {len(backfilled)} profile(s) "
+ f"(copied from default): {', '.join(backfilled)}"
+ )
+ except Exception:
+ pass # profiles module not available or no profiles
+
# Sync Honcho host blocks to all profiles
try:
from plugins.memory.honcho.cli import sync_honcho_profiles_quiet
@@ -9849,7 +9865,10 @@ def cmd_profile(args):
getattr(args, "clone_from", None) or get_active_profile_name()
)
if clone_all:
- print(f"Full copy from {source_label}.")
+ print(
+ f"Full copy from {source_label} "
+ "(excluding session history, backups, and snapshots)."
+ )
else:
print(
f"Cloned config, .env, SOUL.md, and skills from {source_label}."
diff --git a/hermes_cli/models.py b/hermes_cli/models.py
index aa6996a4877d..39decf135faf 100644
--- a/hermes_cli/models.py
+++ b/hermes_cli/models.py
@@ -57,6 +57,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("qwen/qwen3.6-35b-a3b", ""),
# MoonshotAI
("moonshotai/kimi-k2.6", "recommended"),
+ ("moonshotai/kimi-k2.7-code", ""),
# MiniMax
("minimax/minimax-m3", ""),
# Z-AI
@@ -178,6 +179,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"qwen/qwen3.6-35b-a3b",
# MoonshotAI
"moonshotai/kimi-k2.6",
+ "moonshotai/kimi-k2.7-code",
# MiniMax
"minimax/minimax-m3",
# Z-AI
diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py
index d15cd0f7e872..50e5bbeabbc9 100644
--- a/hermes_cli/profiles.py
+++ b/hermes_cli/profiles.py
@@ -88,9 +88,9 @@ _CLONE_ALL_STRIP: list[str] = [
# node_modules — npm packages (hundreds of MB)
#
# See ``_DEFAULT_EXPORT_EXCLUDE_ROOT`` below for the broader export-side
-# exclusion list (export drops state.db / logs / caches too because the
-# archive is a portable snapshot; clone-all keeps those because the cloned
-# profile is meant to keep working immediately).
+# exclusion list (export also drops logs / caches because the archive is a
+# portable snapshot; clone-all keeps those because the cloned profile is
+# meant to keep working immediately).
_CLONE_ALL_DEFAULT_EXCLUDE_ROOT: frozenset[str] = frozenset({
"hermes-agent",
".worktrees",
@@ -99,6 +99,30 @@ _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: frozenset[str] = frozenset({
"node_modules",
})
+# Per-profile history artifacts excluded from --clone-all regardless of the
+# source profile. A new profile is a fresh workspace — inheriting the source
+# profile's session history, backup archives, or quick-backup snapshots is
+# never useful (restoring one inside the clone would resurrect the SOURCE
+# profile's state) and can balloon the copy by tens of GB. Unlike
+# ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` this set is NOT gated on the default
+# profile: named profiles accumulate the same artifacts.
+#
+# Rationale per item:
+# state.db (+wal/shm) — SQLite session store (can reach many GB)
+# sessions — per-session transcript/data dirs
+# backups — `hermes backup` archives
+# state-snapshots — quick-backup snapshot trees
+# checkpoints — session checkpoint data
+_CLONE_ALL_HISTORY_EXCLUDE_ROOT: frozenset[str] = frozenset({
+ "state.db",
+ "state.db-wal",
+ "state.db-shm",
+ "sessions",
+ "backups",
+ "state-snapshots",
+ "checkpoints",
+})
+
# Marker file written by `hermes profile create --no-skills`. When present in
# a profile's root, callers of seed_profile_skills() (fresh-create, `hermes
# update`'s all-profile sync, the web dashboard) skip bundled-skill seeding
@@ -119,13 +143,16 @@ def has_bundled_skills_opt_out(profile_dir: Path) -> bool:
def _clone_all_copytree_ignore(source_dir: Path):
"""Exclude infrastructure artifacts when cloning a profile via --clone-all.
- Two categories:
- 1. Root-level entries in ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` — known
+ Three categories:
+ 1. Root-level entries in ``_CLONE_ALL_HISTORY_EXCLUDE_ROOT`` — session
+ history, backups, and snapshots that belong to the SOURCE profile
+ and should never carry into a fresh clone. Applies to any source.
+ 2. Root-level entries in ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` — known
Hermes infrastructure directories that only the default profile
(``~/.hermes``) ever contains. Gated on ``source_dir`` actually
being the default profile so a named-profile source never has its
own data silently dropped.
- 2. Universal exclusions at any depth — Python bytecode caches that
+ 3. Universal exclusions at any depth — Python bytecode caches that
are stale or regenerable (``__pycache__``, ``*.pyc``, ``*.pyo``)
and runtime sockets / temp files (``*.sock``, ``*.tmp``).
@@ -147,17 +174,21 @@ def _clone_all_copytree_ignore(source_dir: Path):
):
ignored.append(entry)
continue
- # Root-level exclusions only apply when cloning the default profile.
- if is_default_source:
- try:
- if Path(directory).resolve() == source_resolved:
- if entry in _CLONE_ALL_DEFAULT_EXCLUDE_ROOT:
- ignored.append(entry)
- except (OSError, ValueError):
- # ``resolve()`` can fail on unusual FS layouts (broken
- # symlinks, missing parents). Fail open — better to
- # over-copy than silently drop user data.
- pass
+ try:
+ at_root = Path(directory).resolve() == source_resolved
+ except (OSError, ValueError):
+ # ``resolve()`` can fail on unusual FS layouts (broken
+ # symlinks, missing parents). Fail open — better to
+ # over-copy than silently drop user data.
+ at_root = False
+ if at_root:
+ # History artifacts: excluded for ANY source profile.
+ if entry in _CLONE_ALL_HISTORY_EXCLUDE_ROOT:
+ ignored.append(entry)
+ continue
+ # Infrastructure: only the default profile contains these.
+ if is_default_source and entry in _CLONE_ALL_DEFAULT_EXCLUDE_ROOT:
+ ignored.append(entry)
return ignored
return _ignore
@@ -946,6 +977,58 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
return None
+def backfill_profile_envs(quiet: bool = False) -> List[str]:
+ """Give every named profile that predates per-profile ``.env`` files one.
+
+ Profiles created before the dashboard/CLI started seeding a ``.env``
+ (PR #44792) have none, so once the Channels/Keys endpoints became
+ profile-scoped those profiles stopped inheriting the root install's
+ credentials and showed everything as unconfigured. To avoid breaking
+ anyone on update, copy the DEFAULT install's ``.env`` into each named
+ profile that lacks one — that preserves the effective credentials those
+ profiles were already running with (they previously read the root
+ ``.env`` via the process environment). Users can then diverge per
+ profile from there.
+
+ Falls back to the placeholder header when the default install has no
+ ``.env`` itself. Never overwrites an existing profile ``.env``.
+
+ Returns the list of profile names that received a backfilled ``.env``.
+ """
+ backfilled: List[str] = []
+ profiles_root = _get_profiles_root()
+ if not profiles_root.is_dir():
+ return backfilled
+
+ default_env = _get_default_hermes_home() / ".env"
+
+ for entry in sorted(profiles_root.iterdir()):
+ if not entry.is_dir() or not _PROFILE_ID_RE.match(entry.name):
+ continue
+ if entry.name == "default":
+ continue
+ env_path = entry / ".env"
+ if env_path.exists():
+ continue
+ try:
+ if default_env.is_file():
+ shutil.copy2(default_env, env_path)
+ else:
+ env_path.write_text(
+ "# Per-profile secrets for this Hermes profile.\n"
+ "# API keys and tokens set here override the shell environment.\n"
+ "# Behavioral settings belong in config.yaml, not here.\n",
+ encoding="utf-8",
+ )
+ os.chmod(str(env_path), 0o600)
+ backfilled.append(entry.name)
+ except OSError as e:
+ if not quiet:
+ print(f"⚠ Could not seed .env for profile '{entry.name}': {e}")
+
+ return backfilled
+
+
def delete_profile(name: str, yes: bool = False) -> Path:
"""Delete a profile, its wrapper script, and its gateway service.
diff --git a/run_agent.py b/run_agent.py
index 8026a602c685..0be8b1763fa4 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -1560,7 +1560,12 @@ class AIAgent:
if not self._session_db_created:
self._ensure_db_session()
start_idx = len(conversation_history) if conversation_history else 0
- flush_from = max(start_idx, self._last_flushed_db_idx)
+ # Guard against the flush cursor overshooting the message list.
+ # This can happen when repair_message_sequence compacts the list
+ # (merging consecutive users, dropping stray tools) after the
+ # cursor was set. Fall back to start_idx so we don't skip
+ # persisting the assistant/tool chain (#44837).
+ flush_from = max(start_idx, min(self._last_flushed_db_idx, len(messages)))
for msg in messages[flush_from:]:
role = msg.get("role", "unknown")
content = msg.get("content")
diff --git a/skills/media/youtube-content/SKILL.md b/skills/media/youtube-content/SKILL.md
index 32828f75986b..3661acad126a 100644
--- a/skills/media/youtube-content/SKILL.md
+++ b/skills/media/youtube-content/SKILL.md
@@ -14,8 +14,11 @@ Extract transcripts from YouTube videos and convert them into useful formats.
## Setup
+Use `uv` so the dependency is installed into the same Hermes-managed environment
+that runs the helper script:
+
```bash
-pip install youtube-transcript-api
+uv pip install youtube-transcript-api
```
## Helper Script
@@ -24,16 +27,16 @@ pip install youtube-transcript-api
```bash
# JSON output with metadata
-python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID"
+uv run python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID"
# Plain text (good for piping into further processing)
-python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only
+uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only
# With timestamps
-python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps
+uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps
# Specific language with fallback chain
-python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en
+uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en
```
## Output Formats
@@ -59,7 +62,7 @@ After fetching the transcript, format it based on what the user asks for:
## Workflow
-1. **Fetch** the transcript using the helper script with `--text-only --timestamps`.
+1. **Fetch** the transcript using the helper script with `--text-only --timestamps` via `uv run python3`.
2. **Validate**: confirm the output is non-empty and in the expected language. If empty, retry without `--language` to get any available transcript. If still empty, tell the user the video likely has transcripts disabled.
3. **Chunk if needed**: if the transcript exceeds ~50K characters, split into overlapping chunks (~40K with 2K overlap) and summarize each chunk before merging.
4. **Transform** into the requested output format. If the user did not specify a format, default to a summary.
@@ -70,4 +73,4 @@ After fetching the transcript, format it based on what the user asks for:
- **Transcript disabled**: tell the user; suggest they check if subtitles are available on the video page.
- **Private/unavailable video**: relay the error and ask the user to verify the URL.
- **No matching language**: retry without `--language` to fetch any available transcript, then note the actual language to the user.
-- **Dependency missing**: run `pip install youtube-transcript-api` and retry.
+- **Dependency missing**: run `uv pip install youtube-transcript-api` and retry.
diff --git a/skills/media/youtube-content/scripts/fetch_transcript.py b/skills/media/youtube-content/scripts/fetch_transcript.py
index 5ad3e5aa652b..6160339038dd 100644
--- a/skills/media/youtube-content/scripts/fetch_transcript.py
+++ b/skills/media/youtube-content/scripts/fetch_transcript.py
@@ -3,7 +3,7 @@
Fetch a YouTube video transcript and output it as structured JSON.
Usage:
- python fetch_transcript.py [--language en,tr] [--timestamps]
+ uv run python3 fetch_transcript.py [--language en,tr] [--timestamps]
Output (JSON):
{
@@ -14,7 +14,7 @@ Output (JSON):
"timestamped_text": "00:00 first line\n00:05 second line\n..."
}
-Install dependency: pip install youtube-transcript-api
+Install dependency: uv pip install youtube-transcript-api
"""
import argparse
@@ -56,7 +56,7 @@ def fetch_transcript(video_id: str, languages: list = None):
try:
from youtube_transcript_api import YouTubeTranscriptApi
except ImportError:
- print("Error: youtube-transcript-api not installed. Run: pip install youtube-transcript-api",
+ print("Error: youtube-transcript-api not installed. Run: uv pip install youtube-transcript-api",
file=sys.stderr)
sys.exit(1)
diff --git a/tests/agent/test_compressed_summary_metadata.py b/tests/agent/test_compressed_summary_metadata.py
new file mode 100644
index 000000000000..fba47767596a
--- /dev/null
+++ b/tests/agent/test_compressed_summary_metadata.py
@@ -0,0 +1,93 @@
+"""Regression tests for the compressed-summary metadata flag (#38389).
+
+The compressor marks summary messages with ``COMPRESSED_SUMMARY_METADATA_KEY``
+so frontends (CLI, Desktop, gateway, TUI) can distinguish them from real
+assistant/user messages without content-prefix heuristics.
+
+Two invariants:
+1. The flag is present on exactly the summary-bearing message after compress()
+ (standalone insertion AND merge-into-tail).
+2. The key is underscore-prefixed so the chat-completions wire sanitizer
+ strips it — strict gateways (Fireworks, Mistral, Moonshot/Kimi,
+ opencode-go) reject unknown message keys with "Extra inputs are not
+ permitted", poisoning the session.
+"""
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from agent.context_compressor import (
+ COMPRESSED_SUMMARY_METADATA_KEY,
+ ContextCompressor,
+)
+
+
+def _make_compressor():
+ with patch(
+ "agent.context_compressor.get_model_context_length", return_value=8000
+ ):
+ return ContextCompressor(
+ model="test-model", quiet_mode=True, config_context_length=8000
+ )
+
+
+def _make_messages(n_turns=30):
+ msgs = [{"role": "system", "content": "sys"}]
+ for i in range(n_turns):
+ msgs.append({"role": "user", "content": f"question {i} " + "x" * 400})
+ msgs.append({"role": "assistant", "content": f"answer {i} " + "y" * 400})
+ return msgs
+
+
+def _compress(cc, msgs):
+ resp = MagicMock()
+ resp.choices[0].message.content = "## Active Task\nstuff"
+ with patch("agent.context_compressor.call_llm", return_value=resp):
+ return cc.compress(msgs, current_tokens=100_000, force=True)
+
+
+class TestMetadataFlagSet:
+ def test_exactly_one_flagged_message_after_compress(self):
+ cc = _make_compressor()
+ out = _compress(cc, _make_messages())
+ flagged = [
+ m for m in out
+ if isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY)
+ ]
+ assert len(flagged) == 1
+ # The flagged message is the one carrying the compaction handoff.
+ assert "[CONTEXT COMPACTION" in flagged[0]["content"]
+
+ def test_helper_detects_flag(self):
+ assert ContextCompressor._has_compressed_summary_metadata(
+ {COMPRESSED_SUMMARY_METADATA_KEY: True}
+ )
+ assert not ContextCompressor._has_compressed_summary_metadata(
+ {"role": "assistant", "content": "hi"}
+ )
+ assert not ContextCompressor._has_compressed_summary_metadata("not a dict")
+ assert not ContextCompressor._has_compressed_summary_metadata(None)
+
+
+class TestMetadataFlagNeverReachesWire:
+ def test_key_is_underscore_prefixed(self):
+ """The wire sanitizers strip every top-level message key starting
+ with '_'. A bare key would reach strict gateways (Fireworks etc.)
+ and 400 with 'Extra inputs are not permitted'."""
+ assert COMPRESSED_SUMMARY_METADATA_KEY.startswith("_")
+
+ def test_chat_completions_transport_strips_flag(self):
+ from agent.transports.chat_completions import ChatCompletionsTransport
+
+ cc = _make_compressor()
+ out = _compress(cc, _make_messages())
+ wire = ChatCompletionsTransport().convert_messages(out, model="some-model")
+ assert not any(
+ isinstance(m, dict) and COMPRESSED_SUMMARY_METADATA_KEY in m
+ for m in wire
+ )
+ # Sanitization must not destroy the in-process flag on the originals.
+ assert any(
+ isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY)
+ for m in out
+ )
diff --git a/tests/agent/test_compressor_assistant_tail_anchor.py b/tests/agent/test_compressor_assistant_tail_anchor.py
new file mode 100644
index 000000000000..e28bc82139f0
--- /dev/null
+++ b/tests/agent/test_compressor_assistant_tail_anchor.py
@@ -0,0 +1,518 @@
+"""Regression coverage for #29824 — the WebUI session viewer (and TUI
+chat panel) was showing the ``[CONTEXT COMPACTION — REFERENCE ONLY]``
+handoff block in the slot where the user had just been reading the
+assistant's actual reply, because the previously-visible reply got
+rolled into the compaction summary by the token-budget tail walk.
+
+The fix adds ``_ensure_last_assistant_message_in_tail`` — a mirror of
+the existing ``_ensure_last_user_message_in_tail`` (#10896 anchor) —
+that pulls ``cut_idx`` back to include the most recent assistant
+message with non-empty text content, with the standard tool-group
+realignment so we don't orphan a ``tool_call`` / ``tool_result`` pair.
+
+Pinned here:
+
+* ``TestFindLastAssistantMessageIdx`` — pure helper contract:
+ finds the most recent **content-bearing** assistant message,
+ skips tool-call-only stubs, falls back to "any assistant" only
+ when no content-bearing reply exists in the compressible region,
+ honours ``head_end``, returns -1 when there's no assistant at all.
+
+* ``TestEnsureLastAssistantMessageInTail`` — direct: walks
+ ``cut_idx`` back when the last reply is in the compressed middle,
+ is a no-op when it's already in the tail, never crosses
+ ``head_end``, re-aligns through tool groups.
+
+* ``TestFindTailCutByTokensAnchorsAssistant`` — integration with
+ the existing tail-cut path: the exact reporter scenario (long
+ tool-output run after the previously-visible reply) preserves
+ the reply; combines with the user anchor for the same-turn
+ preservation; soft-ceiling overrun no longer hides the reply.
+
+* ``TestCompactionRollupReproduction`` — end-to-end through
+ ``compress()`` with a stubbed summariser: pre-fix the reply
+ text is absorbed into the summary (regression demonstrated by
+ asserting on the OLD behaviour fails); post-fix the reply text
+ is still present in the compressed transcript as a regular
+ assistant message.
+
+* ``TestSourceGuardrail`` — static asserts on
+ ``agent/context_compressor.py`` so a future refactor can't
+ silently drop the anchor.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture()
+def compressor():
+ """ContextCompressor with mocked deps and a tight tail budget so
+ the helpers' anchor behaviour is observable."""
+ from agent.context_compressor import ContextCompressor
+ with patch(
+ "agent.context_compressor.get_model_context_length",
+ return_value=100_000,
+ ):
+ c = ContextCompressor(
+ model="test/model",
+ threshold_percent=0.85,
+ protect_first_n=2,
+ protect_last_n=2,
+ quiet_mode=True,
+ )
+ c.tail_token_budget = 50
+ return c
+
+
+# ---------------------------------------------------------------------------
+# Helper: _find_last_assistant_message_idx
+# ---------------------------------------------------------------------------
+
+
+class TestFindLastAssistantMessageIdx:
+ def test_finds_content_bearing_assistant(self, compressor):
+ messages = [
+ {"role": "system", "content": "sys"},
+ {"role": "user", "content": "q"},
+ {"role": "assistant", "content": "the reply"},
+ ]
+ idx = compressor._find_last_assistant_message_idx(messages, head_end=1)
+ assert idx == 2
+
+ def test_skips_tool_call_only_stub_when_text_reply_exists_earlier(
+ self, compressor
+ ):
+ """An assistant message that only carries ``tool_calls`` (no
+ text content) is not the user-visible reply — the WebUI
+ renders those as small "calling tool X" indicators. The helper
+ must prefer the earlier text reply, which is what the user
+ actually read."""
+ messages = [
+ {"role": "user", "content": "q1"},
+ {"role": "assistant", "content": "VISIBLE REPLY"},
+ {"role": "user", "content": "q2"},
+ {"role": "assistant", "content": None,
+ "tool_calls": [{"function": {"name": "t",
+ "arguments": "{}"}}]},
+ {"role": "tool", "content": "result", "tool_call_id": "c1"},
+ ]
+ idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
+ assert idx == 1, (
+ "Expected the content-bearing assistant reply (1), not the "
+ f"trailing tool-call stub. Got {idx}."
+ )
+
+ def test_empty_string_content_does_not_count_as_visible(self, compressor):
+ """An assistant message with ``content=""`` (only whitespace)
+ is not a visible reply either — common pre-flight stub before
+ the model streams the real answer."""
+ messages = [
+ {"role": "user", "content": "q1"},
+ {"role": "assistant", "content": "earlier reply"},
+ {"role": "user", "content": "q2"},
+ {"role": "assistant", "content": " "}, # blank stub
+ ]
+ idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
+ # Blank-string assistant message does not count — fall back
+ # to the earlier real reply.
+ assert idx == 1
+
+ def test_multimodal_text_block_counts(self, compressor):
+ """An assistant with multimodal list-content carrying a text
+ block (Anthropic / GPT-style ``[{type:text,text:...}]``)
+ counts as content-bearing."""
+ messages = [
+ {"role": "user", "content": "q"},
+ {"role": "assistant",
+ "content": [{"type": "text", "text": "hello"}]},
+ ]
+ idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
+ assert idx == 1
+
+ def test_fallback_to_any_assistant_when_no_content_bearing(
+ self, compressor
+ ):
+ """When there's no text-bearing assistant in the compressible
+ region (fresh multi-step tool sequence), fall back to the
+ most recent assistant of any kind so the anchor still works."""
+ messages = [
+ {"role": "user", "content": "q"},
+ {"role": "assistant", "content": None,
+ "tool_calls": [{"function": {"name": "t",
+ "arguments": "{}"}}]},
+ {"role": "tool", "content": "result", "tool_call_id": "c1"},
+ ]
+ idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
+ assert idx == 1
+
+ def test_returns_negative_one_when_no_assistant(self, compressor):
+ messages = [
+ {"role": "user", "content": "q1"},
+ {"role": "user", "content": "q2"},
+ ]
+ idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
+ assert idx == -1
+
+ def test_respects_head_end_lower_bound(self, compressor):
+ """An assistant message at or before ``head_end`` must be
+ ignored — it's already in the protected head region."""
+ messages = [
+ {"role": "system", "content": "sys"},
+ {"role": "assistant", "content": "in-head reply"}, # idx 1
+ {"role": "user", "content": "q"},
+ ]
+ # head_end=2 means the compressible region starts at index 2;
+ # the assistant at index 1 is in the head and must be skipped.
+ idx = compressor._find_last_assistant_message_idx(messages, head_end=2)
+ assert idx == -1
+
+
+# ---------------------------------------------------------------------------
+# Helper: _ensure_last_assistant_message_in_tail
+# ---------------------------------------------------------------------------
+
+
+class TestEnsureLastAssistantMessageInTail:
+ def test_no_op_when_already_in_tail(self, compressor):
+ messages = [
+ {"role": "user", "content": "q"},
+ {"role": "assistant", "content": "reply"},
+ {"role": "user", "content": "q2"},
+ ]
+ # cut_idx=1 means tail starts at index 1 — the reply is already in tail.
+ new_cut = compressor._ensure_last_assistant_message_in_tail(
+ messages, cut_idx=1, head_end=0
+ )
+ assert new_cut == 1
+
+ def test_walks_cut_idx_back_to_include_reply(self, compressor):
+ messages = [
+ {"role": "user", "content": "q1"},
+ {"role": "assistant", "content": "REPLY"}, # idx 1
+ {"role": "user", "content": "q2"},
+ {"role": "user", "content": "q3"},
+ ]
+ # cut_idx=2 leaves the reply outside the tail; anchor must pull
+ # cut_idx back to 1 so messages[1:] contains the reply.
+ new_cut = compressor._ensure_last_assistant_message_in_tail(
+ messages, cut_idx=2, head_end=0
+ )
+ assert new_cut == 1
+ assert any(
+ isinstance(m.get("content"), str) and "REPLY" in m["content"]
+ for m in messages[new_cut:]
+ )
+
+ def test_never_crosses_head_end(self, compressor):
+ messages = [
+ {"role": "system", "content": "sys"},
+ {"role": "assistant", "content": "in-head"}, # head, must ignore
+ {"role": "user", "content": "q"},
+ ]
+ # head_end=2 ⇒ assistant at idx 1 is in the head; the anchor
+ # finds nothing in the compressible region and is a no-op.
+ new_cut = compressor._ensure_last_assistant_message_in_tail(
+ messages, cut_idx=3, head_end=2
+ )
+ assert new_cut == 3
+
+ def test_re_aligns_through_preceding_tool_group(self, compressor):
+ """When the anchored assistant is preceded by a
+ tool_call/result group, ``_align_boundary_backward`` must pull
+ ``cut_idx`` even further back so the group isn't split — same
+ guarantee as ``_ensure_last_user_message_in_tail``."""
+ messages = [
+ {"role": "user", "content": "q1"},
+ {"role": "assistant", "content": None,
+ "tool_calls": [{"id": "c1",
+ "function": {"name": "t",
+ "arguments": "{}"}}]},
+ {"role": "tool", "content": "result", "tool_call_id": "c1"},
+ {"role": "assistant", "content": "REPLY"}, # idx 3
+ {"role": "user", "content": "q2"},
+ ]
+ # cut_idx=4 leaves the reply outside the tail. Anchor pulls
+ # back to 3, then _align_boundary_backward sees the preceding
+ # tool group and pulls further back to 1 (before the assistant
+ # with tool_calls).
+ new_cut = compressor._ensure_last_assistant_message_in_tail(
+ messages, cut_idx=4, head_end=0
+ )
+ assert new_cut <= 3
+ # The tool_call assistant (1) and its tool_result (2) must NOT
+ # be split: either both in compressed region or both in tail.
+ if new_cut <= 1:
+ # Both in tail — tool group intact.
+ assert messages[new_cut].get("role") == "assistant"
+ else:
+ # Otherwise the anchor must land at the reply itself (3).
+ assert new_cut == 3
+
+
+# ---------------------------------------------------------------------------
+# Integration with _find_tail_cut_by_tokens
+# ---------------------------------------------------------------------------
+
+
+class TestFindTailCutByTokensAnchorsAssistant:
+ def test_reporter_repro_long_tool_run_after_visible_reply(
+ self, compressor
+ ):
+ """The exact #29824 scenario: a tight token budget combined
+ with a long tail of tool-call/result messages after the
+ visible reply. Pre-fix, the token-budget walk hit its ceiling
+ on the tool output and parked ``cut_idx`` past the reply.
+ Post-fix, the assistant anchor pulls it back."""
+ c = compressor
+ c.tail_token_budget = 10 # force min-tail behaviour
+ messages = [
+ {"role": "system", "content": "sys"},
+ {"role": "user", "content": "msg1"}, # head_end=2
+ {"role": "user", "content": "q1"},
+ {"role": "assistant",
+ "content": "PREVIOUSLY VISIBLE REPLY"}, # idx 3
+ {"role": "user", "content": "q2"},
+ {"role": "assistant", "content": None,
+ "tool_calls": [{"id": "c1",
+ "function": {"name": "t",
+ "arguments": "{}"}}]},
+ {"role": "tool", "content": "x" * 200,
+ "tool_call_id": "c1"},
+ ]
+ cut = c._find_tail_cut_by_tokens(messages, head_end=2)
+ tail_contents = [
+ m.get("content") for m in messages[cut:]
+ if isinstance(m.get("content"), str)
+ ]
+ assert any(
+ "PREVIOUSLY VISIBLE REPLY" in (t or "") for t in tail_contents
+ ), (
+ "REGRESSION (#29824): the visible reply was rolled into "
+ f"the compaction summary. Tail contents: {tail_contents!r}"
+ )
+
+ def test_user_and_assistant_anchors_compose(self, compressor):
+ """Both anchors run in sequence; the tail must contain both
+ the latest user message AND the latest visible assistant
+ reply."""
+ c = compressor
+ c.tail_token_budget = 10
+ messages = [
+ {"role": "user", "content": "q1"},
+ {"role": "assistant", "content": "VISIBLE REPLY"},
+ {"role": "user", "content": "follow-up question"},
+ {"role": "user", "content": "and another"},
+ ]
+ cut = c._find_tail_cut_by_tokens(messages, head_end=0)
+ tail_contents = [
+ m.get("content") for m in messages[cut:]
+ if isinstance(m.get("content"), str)
+ ]
+ assert any("VISIBLE REPLY" in (t or "") for t in tail_contents)
+ assert any("and another" in (t or "") for t in tail_contents)
+
+ def test_oversized_tool_output_does_not_strand_reply(self, compressor):
+ """The soft-ceiling logic in ``_find_tail_cut_by_tokens``
+ permits a single oversized tail message; the assistant anchor
+ must still recover the reply on the other side of it."""
+ c = compressor
+ c.tail_token_budget = 100 # soft ceiling 150
+ messages = [
+ {"role": "user", "content": "earlier"},
+ {"role": "assistant", "content": "VISIBLE REPLY"},
+ {"role": "user", "content": "read big file"},
+ {"role": "assistant", "content": None,
+ "tool_calls": [{"id": "c1",
+ "function": {"name": "read",
+ "arguments": "{}"}}]},
+ # ~500 chars ⇒ ~135 tokens, blows past soft ceiling of 150
+ {"role": "tool", "content": "y" * 500,
+ "tool_call_id": "c1"},
+ {"role": "user", "content": "ok"},
+ ]
+ cut = c._find_tail_cut_by_tokens(messages, head_end=0)
+ tail_contents = [
+ m.get("content") for m in messages[cut:]
+ if isinstance(m.get("content"), str)
+ ]
+ assert any("VISIBLE REPLY" in (t or "") for t in tail_contents)
+
+
+# ---------------------------------------------------------------------------
+# End-to-end: compress() preserves the reply
+# ---------------------------------------------------------------------------
+
+
+class TestCompactionRollupReproduction:
+ """End-to-end through ``compress()``: the visible reply text must
+ survive in the compressed transcript — either as its own
+ standalone assistant message OR concatenated onto the merged
+ summary-handoff tail message (the compressor's double-collision
+ fallback path; the WebUI re-splits these on the END marker so the
+ reply renders as a separate bubble — see ``splitCompactionContent``
+ in ``web/src/pages/SessionsPage.tsx``)."""
+
+ def test_compress_keeps_visible_reply_text(self, compressor):
+ from agent.context_compressor import SUMMARY_PREFIX
+ c = compressor
+ c.tail_token_budget = 10
+ # ``_generate_summary`` normally wraps the LLM body in
+ # ``SUMMARY_PREFIX`` via ``_with_summary_prefix``; mimic that so
+ # the merge-into-tail branch can identify the boundary.
+ _mocked = f"{SUMMARY_PREFIX}\nrolled-up middle summary"
+ messages = (
+ [{"role": "system", "content": "sys"},
+ {"role": "user", "content": "initial"}] # head (protect_first_n=2)
+ # Middle: long enough to be compressible.
+ + [
+ {"role": "user", "content": f"middle q{i}"}
+ if i % 2 == 0
+ else {"role": "assistant", "content": f"middle reply {i}"}
+ for i in range(12)
+ ]
+ + [
+ {"role": "user", "content": "the visible question"},
+ {"role": "assistant",
+ "content": "THE VISIBLE REPLY THE USER JUST READ"},
+ {"role": "user", "content": "follow up"},
+ {"role": "assistant", "content": None,
+ "tool_calls": [{"id": "c1",
+ "function": {"name": "t",
+ "arguments": "{}"}}]},
+ {"role": "tool", "content": "z" * 500,
+ "tool_call_id": "c1"},
+ ]
+ )
+ with patch.object(
+ c, "_generate_summary",
+ return_value=_mocked,
+ ):
+ result = c.compress(messages, current_tokens=90_000)
+ # 1. A summary message exists (compression actually ran).
+ assert any(
+ isinstance(m.get("content"), str)
+ and m["content"].startswith(SUMMARY_PREFIX)
+ for m in result
+ ), "compress() did not insert a summary message"
+ # 2. The visible reply text must survive somewhere — either
+ # as its own message OR concatenated into the merged tail.
+ joined = "\n".join(
+ m.get("content") for m in result
+ if isinstance(m.get("content"), str)
+ )
+ assert "THE VISIBLE REPLY THE USER JUST READ" in joined, (
+ "REGRESSION (#29824): the visible reply was absorbed into "
+ "the compaction summary AND erased. Compressed transcript "
+ f"({len(result)} msgs): "
+ f"{[(m.get('role'), str(m.get('content'))[:50]) for m in result]}"
+ )
+
+ def test_standalone_summary_case_keeps_reply_as_own_message(
+ self, compressor
+ ):
+ """When the head and tail roles allow a standalone summary
+ message (no double-collision), the visible reply must remain
+ as its OWN assistant message — not merged with anything.
+ This is the common case; the merge-into-tail path is the
+ edge case for double-collision."""
+ from agent.context_compressor import SUMMARY_PREFIX
+ c = compressor
+ c.tail_token_budget = 10
+ _mocked = f"{SUMMARY_PREFIX}\nrolled-up middle summary"
+ # Head ends with ``assistant`` ⇒ summary_role flips to
+ # ``user`` ⇒ no collision with the assistant tail ⇒ standalone
+ # summary insert (no merge).
+ messages = (
+ [
+ {"role": "user", "content": "initial"},
+ {"role": "assistant", "content": "head reply"},
+ ]
+ + [
+ {"role": "user", "content": f"middle q{i}"}
+ if i % 2 == 0
+ else {"role": "assistant", "content": f"middle reply {i}"}
+ for i in range(12)
+ ]
+ + [
+ {"role": "user", "content": "the visible question"},
+ {"role": "assistant",
+ "content": "THE VISIBLE REPLY THE USER JUST READ"},
+ {"role": "user", "content": "follow up"},
+ ]
+ )
+ with patch.object(
+ c, "_generate_summary",
+ return_value=_mocked,
+ ):
+ result = c.compress(messages, current_tokens=90_000)
+ # Standalone summary present:
+ summary_rows = [
+ m for m in result
+ if isinstance(m.get("content"), str)
+ and m["content"].startswith(SUMMARY_PREFIX)
+ ]
+ assert len(summary_rows) == 1
+ # Visible reply as its OWN distinct assistant message
+ # (NOT merged into the summary row):
+ reply_rows = [
+ m for m in result
+ if m.get("role") == "assistant"
+ and isinstance(m.get("content"), str)
+ and "THE VISIBLE REPLY THE USER JUST READ" in m["content"]
+ and not m["content"].startswith(SUMMARY_PREFIX)
+ ]
+ assert len(reply_rows) == 1, (
+ "REGRESSION (#29824): expected exactly one standalone "
+ f"assistant message carrying the visible reply, got "
+ f"{len(reply_rows)}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Source guardrail
+# ---------------------------------------------------------------------------
+
+
+class TestSourceGuardrail:
+ @pytest.fixture
+ def source(self) -> str:
+ from pathlib import Path
+ return (Path(__file__).resolve().parents[2]
+ / "agent" / "context_compressor.py").read_text(
+ encoding="utf-8")
+
+ def test_helper_defined(self, source):
+ assert "def _find_last_assistant_message_idx(" in source
+ assert "def _ensure_last_assistant_message_in_tail(" in source
+
+ def test_anchor_called_from_find_tail_cut(self, source):
+ """Without the call site the helper is dead code and the bug
+ regresses silently — pin both the definition AND the wiring."""
+ assert "self._ensure_last_assistant_message_in_tail(" in source
+
+ def test_anchor_called_after_user_anchor(self, source):
+ """The two anchors must run in sequence; reversing or skipping
+ one drops the corresponding side of the guarantee."""
+ user_call = "self._ensure_last_user_message_in_tail(messages, cut_idx, head_end)"
+ asst_call = "self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end)"
+ user_idx = source.find(user_call)
+ asst_idx = source.find(asst_call)
+ assert user_idx >= 0 and asst_idx >= 0
+ assert asst_idx > user_idx, (
+ "The assistant anchor must come AFTER the user anchor in "
+ "``_find_tail_cut_by_tokens`` — each anchor walks cut_idx "
+ "backward, and ordering keeps the chain monotonic."
+ )
+
+ def test_helper_prefers_content_bearing_reply(self, source):
+ """The helper must skip tool-call-only stubs — that's the
+ whole user-experience difference between #29824 (no visible
+ reply) and an in-progress turn (small 'calling tool X' chip)."""
+ assert "content.strip()" in source
+
+ def test_issue_number_referenced(self, source):
+ assert "#29824" in source
diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py
index 0c56da2687ee..7eb1e8a57b02 100644
--- a/tests/agent/test_context_compressor.py
+++ b/tests/agent/test_context_compressor.py
@@ -1252,6 +1252,48 @@ class TestCompressWithClient:
"respond to the message below, not the summary above ---"
)
+ def test_assistant_role_summary_carries_end_marker(self):
+ """When the summary lands as standalone role='assistant' (head ends
+ with user), the message body must include the explicit
+ '--- END OF CONTEXT SUMMARY ---' marker. Without it, models may
+ regurgitate the summary text as their own output (#33256).
+ """
+ mock_client = MagicMock()
+ mock_response = MagicMock()
+ mock_response.choices = [MagicMock()]
+ mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: stuff happened"
+ mock_client.chat.completions.create.return_value = mock_response
+
+ with patch("agent.context_compressor.get_model_context_length", return_value=100000):
+ c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
+
+ # head_last=user → summary_role="assistant" (same setup as
+ # test_summary_role_avoids_consecutive_user_when_head_ends_with_user).
+ # With min_tail=3, tail = last 3 messages (indices 5-7).
+ # head_last=user, tail_first=user → the assistant-role summary does
+ # not collide with either neighbor and should be inserted standalone.
+ msgs = [
+ {"role": "system", "content": "system prompt"},
+ {"role": "user", "content": "msg 1"},
+ {"role": "user", "content": "msg 2"}, # last head — user
+ {"role": "assistant", "content": "msg 3"},
+ {"role": "user", "content": "msg 4"},
+ {"role": "user", "content": "msg 5"},
+ {"role": "assistant", "content": "msg 6"},
+ {"role": "user", "content": "msg 7"},
+ ]
+ with patch("agent.context_compressor.call_llm", return_value=mock_response):
+ result = c.compress(msgs)
+
+ summary_msg = next(
+ m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX)
+ )
+ assert summary_msg["role"] == "assistant"
+ assert "END OF CONTEXT SUMMARY" in summary_msg["content"]
+ assert summary_msg["content"].rstrip().endswith(
+ "respond to the message below, not the summary above ---"
+ )
+
def test_summary_role_avoids_consecutive_user_messages(self):
"""Summary role should alternate with the last head message to avoid consecutive same-role messages."""
mock_client = MagicMock()
@@ -1762,6 +1804,40 @@ class TestTokenBudgetTailProtection:
tail_size = len(messages) - cut
assert tail_size >= 3, f"Tail is only {tail_size} messages, min should be 3"
+ def test_tiny_budget_preserves_bounded_recent_turns(self, budget_compressor):
+ """A token-exhausted tail must preserve more than just the latest ask.
+
+ Regression for #9413: the previous hard-coded 3-message floor could
+ leave the latest user message live while summarizing the assistant/tool
+ context immediately before it, which made the post-compression turn feel
+ like a fresh conversation.
+ """
+ c = budget_compressor
+ c.tail_token_budget = 10
+ c.protect_last_n = 20
+ messages = [
+ {"role": "system", "content": "sys"},
+ {"role": "user", "content": "old start"},
+ {"role": "assistant", "content": "old ack"},
+ {"role": "user", "content": "middle work"},
+ {"role": "assistant", "content": "middle ack"},
+ {"role": "user", "content": "middle ask 2"},
+ {"role": "assistant", "content": "middle answer 2"},
+ {"role": "user", "content": "middle ask 3"},
+ {"role": "assistant", "content": "middle answer 3"},
+ {"role": "user", "content": "recent ask 1"},
+ {"role": "assistant", "content": "recent answer 1"},
+ {"role": "user", "content": "recent ask 2"},
+ {"role": "assistant", "content": "recent answer 2"},
+ {"role": "user", "content": "latest ask"},
+ ]
+
+ cut = c._find_tail_cut_by_tokens(messages, head_end=1)
+
+ assert len(messages) - cut >= 8
+ assert messages[cut]["content"] == "middle answer 2"
+ assert messages[-1]["content"] == "latest ask"
+
def test_soft_ceiling_allows_oversized_message(self, budget_compressor):
"""The 1.5x soft ceiling allows an oversized message to be included
rather than splitting it."""
diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py
index 31d56b9b9839..2a23b648baa7 100644
--- a/tests/hermes_cli/test_profiles.py
+++ b/tests/hermes_cli/test_profiles.py
@@ -33,6 +33,7 @@ from hermes_cli.profiles import (
seed_profile_skills,
has_bundled_skills_opt_out,
NO_BUNDLED_SKILLS_MARKER,
+ backfill_profile_envs,
)
@@ -268,9 +269,9 @@ class TestCreateProfile:
def test_clone_all_excludes_default_infrastructure(self, profile_env):
"""--clone-all from default profile excludes hermes-agent, .worktrees,
bin, node_modules at root, plus __pycache__/*.pyc/*.pyo/*.sock/*.tmp
- at any depth. Profile data (config, env, skills, sessions, logs,
- state.db) must be preserved — clone-all means "complete snapshot
- minus infrastructure."
+ at any depth. Profile data (config, env, skills, logs) must be
+ preserved — clone-all means "complete snapshot minus infrastructure
+ and per-profile history."
"""
tmp_path = profile_env
default_home = tmp_path / ".hermes"
@@ -296,8 +297,6 @@ class TestCreateProfile:
(default_home / "skills" / "my-skill" / "SKILL.md").write_text("skill")
(default_home / "config.yaml").write_text("model: gpt-4")
(default_home / ".env").write_text("KEY=val")
- (default_home / "state.db").write_text("sessions-data")
- (default_home / "sessions").mkdir(exist_ok=True)
(default_home / "logs").mkdir(exist_ok=True)
(default_home / "logs" / "gateway.log").write_text("log")
@@ -319,10 +318,40 @@ class TestCreateProfile:
assert (profile_dir / "skills" / "my-skill" / "SKILL.md").read_text() == "skill"
assert (profile_dir / "config.yaml").read_text() == "model: gpt-4"
assert (profile_dir / ".env").read_text() == "KEY=val"
- assert (profile_dir / "state.db").read_text() == "sessions-data"
- assert (profile_dir / "sessions").exists()
assert (profile_dir / "logs" / "gateway.log").read_text() == "log"
+ def test_clone_all_excludes_history_artifacts(self, profile_env):
+ """--clone-all excludes the source's session history, backups, and
+ snapshots — a clone is a fresh workspace, and these can reach tens
+ of GB. Applies to ANY source profile, not just default.
+ """
+ tmp_path = profile_env
+ default_home = tmp_path / ".hermes"
+ (default_home / "state.db").write_text("sessions-data")
+ (default_home / "state.db-wal").write_text("wal")
+ (default_home / "state.db-shm").write_text("shm")
+ (default_home / "sessions" / "20260101_old").mkdir(parents=True)
+ (default_home / "backups").mkdir(exist_ok=True)
+ (default_home / "backups" / "backup.tar.gz").write_text("archive")
+ (default_home / "state-snapshots" / "snap1").mkdir(parents=True)
+ (default_home / "checkpoints" / "cp1").mkdir(parents=True)
+ # Data that should still copy
+ (default_home / "config.yaml").write_text("model: gpt-4")
+ # Nested dirs with the same names must NOT be excluded (root-only)
+ (default_home / "workspace" / "backups").mkdir(parents=True)
+ (default_home / "workspace" / "backups" / "user-data.txt").write_text("mine")
+
+ profile_dir = create_profile("fresh", clone_all=True, no_alias=True)
+
+ for history in (
+ "state.db", "state.db-wal", "state.db-shm",
+ "sessions", "backups", "state-snapshots", "checkpoints",
+ ):
+ assert not (profile_dir / history).exists(), history
+ assert (profile_dir / "config.yaml").read_text() == "model: gpt-4"
+ # Root-only: nested same-name dirs survive
+ assert (profile_dir / "workspace" / "backups" / "user-data.txt").read_text() == "mine"
+
def test_clone_config_missing_files_skipped(self, profile_env):
"""Clone config gracefully skips files that don't exist in source."""
profile_dir = create_profile("coder", clone_config=True, no_alias=True)
@@ -445,6 +474,60 @@ class TestNoSkillsOptOut:
assert len(called) == 1
+# ===================================================================
+# TestBackfillProfileEnvs
+# ===================================================================
+
+class TestBackfillProfileEnvs:
+ """Tests for backfill_profile_envs() — the `hermes update` pass that
+ gives pre-#44792 profiles (created before .env seeding) their own
+ .env, copied from the default install so credentials don't break."""
+
+ def test_copies_default_env_into_envless_profiles(self, profile_env):
+ import stat
+ tmp_path = profile_env
+ (tmp_path / ".hermes" / ".env").write_text("OPENROUTER_API_KEY=root-key\n")
+ p1 = create_profile("old1", no_alias=True)
+ p2 = create_profile("old2", no_alias=True)
+ # Simulate pre-#44792 profiles: no .env
+ (p1 / ".env").unlink()
+ (p2 / ".env").unlink()
+
+ backfilled = backfill_profile_envs(quiet=True)
+
+ assert sorted(backfilled) == ["old1", "old2"]
+ for p in (p1, p2):
+ assert (p / ".env").read_text() == "OPENROUTER_API_KEY=root-key\n"
+ assert stat.S_IMODE((p / ".env").stat().st_mode) == 0o600
+
+ def test_never_overwrites_existing_profile_env(self, profile_env):
+ tmp_path = profile_env
+ (tmp_path / ".hermes" / ".env").write_text("KEY=root\n")
+ p = create_profile("hasenv", no_alias=True)
+ (p / ".env").write_text("KEY=mine\n")
+
+ backfilled = backfill_profile_envs(quiet=True)
+
+ assert backfilled == []
+ assert (p / ".env").read_text() == "KEY=mine\n"
+
+ def test_placeholder_when_default_has_no_env(self, profile_env):
+ p = create_profile("noroot", no_alias=True)
+ (p / ".env").unlink()
+
+ backfilled = backfill_profile_envs(quiet=True)
+
+ assert backfilled == ["noroot"]
+ content = (p / ".env").read_text(encoding="utf-8")
+ assert all(
+ line.startswith("#") or not line.strip()
+ for line in content.splitlines()
+ )
+
+ def test_no_profiles_root_is_noop(self, profile_env):
+ assert backfill_profile_envs(quiet=True) == []
+
+
# ===================================================================
# TestDeleteProfile
# ===================================================================
diff --git a/tests/run_agent/test_message_sequence_repair.py b/tests/run_agent/test_message_sequence_repair.py
index fd1db95e8436..8fba45ebeb2a 100644
--- a/tests/run_agent/test_message_sequence_repair.py
+++ b/tests/run_agent/test_message_sequence_repair.py
@@ -199,3 +199,102 @@ def test_repair_preserves_system_messages():
AIAgent._repair_message_sequence(agent, messages)
assert messages == original
+
+
+# ── repair_message_sequence_with_cursor (#44837) ───────────────────────────
+
+from agent.agent_runtime_helpers import repair_message_sequence_with_cursor
+
+
+def test_cursor_clamped_when_compaction_shrinks_below_cursor():
+ """Cursor past the new end of the list must come back in range so the
+ turn-end flush doesn't skip the assistant/tool chain (#44837)."""
+ agent = _bare_agent()
+ messages = [
+ {"role": "user", "content": "first"},
+ {"role": "user", "content": "second"},
+ ]
+ agent._last_flushed_db_idx = 2 # both rows already flushed
+
+ repairs = repair_message_sequence_with_cursor(agent, messages)
+
+ assert repairs == 1
+ assert len(messages) == 1
+ assert agent._last_flushed_db_idx == 1
+
+
+def test_cursor_rewinds_when_compaction_happens_before_cursor():
+ """Repair that drops/merges messages at indexes BELOW the cursor must
+ rewind it by the number removed, or unflushed rows get skipped.
+ A plain min() clamp does NOT catch this case."""
+ agent = _bare_agent()
+ flushed_a = {"role": "user", "content": "first"}
+ flushed_b = {"role": "user", "content": "second"} # merged into flushed_a
+ unflushed_assistant = {"role": "assistant", "content": "answer"}
+ messages = [flushed_a, flushed_b, unflushed_assistant]
+ agent._last_flushed_db_idx = 2 # the two user rows are flushed
+
+ repairs = repair_message_sequence_with_cursor(agent, messages)
+
+ assert repairs == 1
+ assert len(messages) == 2
+ # Cursor must now point at the assistant (index 1), not stay at 2 —
+ # min(2, len=2) would leave it at 2 and the flush would skip it.
+ assert agent._last_flushed_db_idx == 1
+ assert messages[agent._last_flushed_db_idx] is unflushed_assistant
+
+
+def test_cursor_untouched_when_no_repairs():
+ agent = _bare_agent()
+ messages = [
+ {"role": "user", "content": "hi"},
+ {"role": "assistant", "content": "hello"},
+ ]
+ agent._last_flushed_db_idx = 1
+
+ repairs = repair_message_sequence_with_cursor(agent, messages)
+
+ assert repairs == 0
+ assert agent._last_flushed_db_idx == 1
+
+
+def test_cursor_helper_safe_without_cursor_attribute():
+ """Bare agents (no _last_flushed_db_idx) must not crash."""
+ agent = _bare_agent()
+ messages = [
+ {"role": "user", "content": "a"},
+ {"role": "user", "content": "b"},
+ ]
+
+ repairs = repair_message_sequence_with_cursor(agent, messages)
+
+ assert repairs == 1
+ assert not hasattr(agent, "_last_flushed_db_idx")
+
+
+def test_flush_guard_clamps_overshooting_cursor():
+ """_flush_messages_to_session_db safety net: an overshooting cursor must
+ not produce a negative-start slice that skips everything (#44837)."""
+
+ class _DB:
+ def __init__(self):
+ self.rows = []
+
+ def append_message(self, **kw):
+ self.rows.append(kw)
+
+ agent = _bare_agent()
+ agent._session_db = _DB()
+ agent._session_db_created = True
+ agent.session_id = "s1"
+ agent._persist_user_message_override = None
+ agent._last_flushed_db_idx = 5 # stale — past end of compacted list
+ messages = [
+ {"role": "user", "content": "q"},
+ {"role": "assistant", "content": "a"},
+ ]
+
+ AIAgent._flush_messages_to_session_db(agent, messages, conversation_history=[])
+
+ # min(5, 2) = 2 → nothing skipped below start_idx, cursor settles at 2
+ assert agent._last_flushed_db_idx == 2
diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx
index 34a68800d060..c48d24538766 100644
--- a/web/src/pages/SessionsPage.tsx
+++ b/web/src/pages/SessionsPage.tsx
@@ -147,6 +147,60 @@ 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 (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;
+
+// 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,
@@ -180,12 +234,60 @@ 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;
+ // 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;
+ 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 = (() => {
diff --git a/website/docs/reference/profile-commands.md b/website/docs/reference/profile-commands.md
index 922de3790cfc..a1b09ed9a593 100644
--- a/website/docs/reference/profile-commands.md
+++ b/website/docs/reference/profile-commands.md
@@ -81,7 +81,7 @@ Creates a new profile.
|-------------------|-------------|
| `` | Name for the new profile. Must be a valid directory name (alphanumeric, hyphens, underscores). |
| `--clone` | Copy `config.yaml`, `.env`, and `SOUL.md` from the current profile. |
-| `--clone-all` | Copy everything (config, memories, skills, sessions, state) from the current profile. |
+| `--clone-all` | Copy everything (config, memories, skills, cron, plugins) from the current profile. Excludes per-profile history: sessions, `state.db`, backups, state-snapshots, checkpoints. |
| `--clone-from ` | Clone from a specific profile instead of the current one. Used with `--clone` or `--clone-all`. |
| `--no-alias` | Skip wrapper script creation. |
| `--description ""` | One- or two-sentence description of what this profile is good at. Used by the kanban orchestrator to route tasks based on role instead of profile name alone. Skip and add later via `hermes profile describe`. Persisted in `/profile.yaml`. |
diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md
index 2efb2a9f867b..6e583db2b005 100644
--- a/website/docs/user-guide/profiles.md
+++ b/website/docs/user-guide/profiles.md
@@ -58,7 +58,7 @@ Copies your current profile's `config.yaml`, `.env`, and `SOUL.md` into the new
hermes profile create backup --clone-all
```
-Copies **everything** — config, API keys, personality, all memories, full session history, skills, cron jobs, plugins. A complete snapshot. Useful for backups or forking an agent that already has context.
+Copies **everything** — config, API keys, personality, all memories, skills, cron jobs, plugins. A complete working snapshot. Per-profile history is excluded (session history, `state.db`, `backups/`, `state-snapshots/`, `checkpoints/`) — these belong to the source profile and can reach tens of GB. For a full backup including history, use `hermes profile export` or `hermes backup` instead.
### Clone from a specific profile
diff --git a/website/docs/user-guide/skills/bundled/media/media-youtube-content.md b/website/docs/user-guide/skills/bundled/media/media-youtube-content.md
index 24f8871a9724..2971a56f9551 100644
--- a/website/docs/user-guide/skills/bundled/media/media-youtube-content.md
+++ b/website/docs/user-guide/skills/bundled/media/media-youtube-content.md
@@ -34,8 +34,11 @@ Extract transcripts from YouTube videos and convert them into useful formats.
## Setup
+Use `uv` so the dependency is installed into the same Hermes-managed environment
+that runs the helper script:
+
```bash
-pip install youtube-transcript-api
+uv pip install youtube-transcript-api
```
## Helper Script
@@ -44,16 +47,16 @@ pip install youtube-transcript-api
```bash
# JSON output with metadata
-python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID"
+uv run python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID"
# Plain text (good for piping into further processing)
-python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only
+uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only
# With timestamps
-python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps
+uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps
# Specific language with fallback chain
-python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en
+uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en
```
## Output Formats
@@ -79,7 +82,7 @@ After fetching the transcript, format it based on what the user asks for:
## Workflow
-1. **Fetch** the transcript using the helper script with `--text-only --timestamps`.
+1. **Fetch** the transcript using the helper script with `--text-only --timestamps` via `uv run python3`.
2. **Validate**: confirm the output is non-empty and in the expected language. If empty, retry without `--language` to get any available transcript. If still empty, tell the user the video likely has transcripts disabled.
3. **Chunk if needed**: if the transcript exceeds ~50K characters, split into overlapping chunks (~40K with 2K overlap) and summarize each chunk before merging.
4. **Transform** into the requested output format. If the user did not specify a format, default to a summary.
@@ -90,4 +93,4 @@ After fetching the transcript, format it based on what the user asks for:
- **Transcript disabled**: tell the user; suggest they check if subtitles are available on the video page.
- **Private/unavailable video**: relay the error and ask the user to verify the URL.
- **No matching language**: retry without `--language` to fetch any available transcript, then note the actual language to the user.
-- **Dependency missing**: run `pip install youtube-transcript-api` and retry.
+- **Dependency missing**: run `uv pip install youtube-transcript-api` and retry.
diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json
index 11518d7b414b..b028ecd92457 100644
--- a/website/static/api/model-catalog.json
+++ b/website/static/api/model-catalog.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updated_at": "2026-06-09T17:20:16Z",
+ "updated_at": "2026-06-12T23:38:08Z",
"metadata": {
"source": "hermes-agent repo",
"docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog"
@@ -84,6 +84,10 @@
"id": "moonshotai/kimi-k2.6",
"description": "recommended"
},
+ {
+ "id": "moonshotai/kimi-k2.7-code",
+ "description": ""
+ },
{
"id": "minimax/minimax-m3",
"description": ""
@@ -199,6 +203,9 @@
{
"id": "moonshotai/kimi-k2.6"
},
+ {
+ "id": "moonshotai/kimi-k2.7-code"
+ },
{
"id": "minimax/minimax-m3"
},