Skip to content

fix(dashboard): strip compaction scaffolding from session messages - #91810

Open
pierrenode wants to merge 1 commit into
NousResearch:mainfrom
pierrenode:fix/dashboard-session-messages-compaction-leak
Open

fix(dashboard): strip compaction scaffolding from session messages#91810
pierrenode wants to merge 1 commit into
NousResearch:mainfrom
pierrenode:fix/dashboard-session-messages-compaction-leak

Conversation

@pierrenode

Copy link
Copy Markdown
Contributor

Summary

agent/compaction_display.py::project_compaction_message_for_display() strips model-only compaction carrier state (handoff boilerplate, inherited tool_calls/reasoning fields) before conversation history reaches a client. It was wired into tui_gateway/server.py, gateway/run.py, and gateway/platforms/api_server.py by the same-day "hide compaction carriers across surfaces" cluster (#91517) — but hermes_cli/web_routers/sessions.py, the dashboard's own FastAPI session-messages endpoint, was never touched. grep -n "compaction" hermes_cli/web_routers/sessions.py returned zero hits before this fix, and #91517's own file list (checked via gh pr view --json files) doesn't include this file.

GET /api/sessions/{id}/messages feeds the dashboard's expanded message view — verified both consumers (web/src/pages/SessionsPage.tsx, apps/desktop/src/api/sessions.ts) use it purely for on-screen rendering. After a session compacts, a row from this endpoint carries the full "[CONTEXT COMPACTION — REFERENCE ONLY] ..." handoff boilerplate (~700 words of internal LLM-steering instructions) plus raw tool_calls/reasoning_content/codex_reasoning_items for merged carriers — exactly the class of leak the same-day cluster set out to close.

Scope decision: /export intentionally NOT touched

I initially assumed both get_session_messages and export_session_endpoint needed the fix, but checked further before writing it. import_sessions_endpoint's own docstring says: "Import one or more sessions exported from the dashboard or CLI" — the dashboard's /export is round-trip-compatible with /api/sessions/import. Stripping compaction carrier state from export would silently corrupt a re-imported session's recovery history (lost tool_calls/reasoning_content/etc. on the target machine). This mirrors the CLI's hermes sessions export (a full-fidelity backup tool), which the same reasoning already exempts elsewhere in this codebase. I locked this decision in with a regression-guard test (test_export_session_keeps_full_fidelity_compaction_content).

Fix

Small local wrapper _project_dashboard_message() mirroring gateway/platforms/api_server.py's existing _project_client_message(): standalone handoffs become hidden empty rows (not dropped — pagination row-counts stay stable) rather than raw boilerplate; merged carriers keep only the real prior-tail content. Applied to every message returned by get_session_messages.

Test plan

  • test_get_session_messages_strips_compaction_scaffolding: standalone handoff → hidden empty row with no inherited carrier fields; merged carrier → only real prior-tail content survives; a genuinely live message is untouched; raw marker text never appears anywhere in the response.
  • test_get_session_messages_hidden_summary_preserves_pagination_count: a hidden row still counts toward pagination.returned (negative control — passes with or without the fix, since it tests row identity, not content).
  • test_export_session_keeps_full_fidelity_compaction_content: regression guard locking in the export-stays-raw scope decision.
  • Mutation-verified: stashed the production fix, the scaffolding-stripping test failed showing the exact raw boilerplate text leaking through; the pagination-count test correctly still passed (legitimate negative control, unaffected by the fix either way).
  • Caught while writing the tests: my first draft put reasoning_content on a role="user" row — verified directly against SessionDB that reasoning_content/codex_reasoning_items only persist for role="assistant" rows, which would have made those specific assertions vacuously true regardless of the fix. Moved to role="assistant", matching how these carriers actually arrive.
  • Full tests/hermes_cli/test_web_server.py: 167 passed, 1 pre-existing skip (unrelated).
  • tests/test_web_server_sessiondb_eventloop.py: 3/3 passed (neighbor suite touching the same DB-access pattern).
  • Full hermes_cli.web_server.app still imports cleanly (311 routes) — no circular-import issue from the new top-level agent.compaction_display import.
  • ruff check clean on both changed files.
  • Could not run the sibling tests/gateway/test_api_server_compaction_projection.py (same projection mechanism, api_server.py's surface) in this sandbox: aiohttp lives under the optional [messaging]/[slack]/[matrix] extras, not [dev], and isn't installed here — import fails at collection, unrelated to this change.

Competing PRs

Checked gh pr list --search with several keyword combinations right before opening this PR. #69321 ("paginate compacted session history") touches the same get_session_messages function body (textual overlap: include_compacted/from_end/total-count pagination work) — but it's 3 weeks stale relative to current main (last updated 2026-07-30, base diff predates the order/Query(...) validation and include_compacted param that already exist on current main, mergeable: UNKNOWN) and orthogonal in intent (pagination semantics vs. compaction-scaffolding stripping). No semantic conflict. #59585/#60878/#85611 were checked and confirmed unrelated (pre-date this projection mechanism or touch different files). #57741 (PII redaction) has zero file overlap.

agent/compaction_display.py::project_compaction_message_for_display()
strips model-only compaction carrier state (handoff boilerplate,
inherited tool_calls/reasoning) before conversation history reaches a
client. It was wired into tui_gateway/server.py, gateway/run.py, and
gateway/platforms/api_server.py (the "hide compaction carriers across
surfaces" cluster, NousResearch#91517, merged same day as this window) — but
hermes_cli/web_routers/sessions.py, the dashboard's own FastAPI
session-messages endpoint, was never touched. grep confirmed zero
references to compaction anywhere in that file before this fix.

GET /api/sessions/{id}/messages feeds the dashboard's expanded
message view (web/src/pages/SessionsPage.tsx and
apps/desktop/src/api/sessions.ts both consume it purely for on-screen
rendering — verified, neither round-trips it through import). After a
session compacts, a row from this endpoint carries the full
"[CONTEXT COMPACTION — REFERENCE ONLY] ..." handoff boilerplate
(~700 words of internal LLM-steering instructions) plus raw
tool_calls/reasoning_content/codex_reasoning_items for merged
carriers — exactly the leak the same-day cluster set out to close.

Scope note: GET /api/sessions/{id}/export was deliberately NOT
touched. Its own docstring on the paired import endpoint says it must
stay round-trip-compatible ("Import one or more sessions exported
from the dashboard or CLI") — stripping carrier state there would
silently corrupt a re-imported session's recovery history. This
mirrors the CLI's `hermes sessions export` (a full-fidelity backup
tool), which is display_compaction_display's own documented
non-target.

Fix: reuse project_compaction_message_for_display() via a small local
wrapper (_project_dashboard_message) mirroring api_server.py's
existing _project_client_message — standalone handoffs become hidden
empty rows (not dropped, so pagination row-counts stay stable) rather
than raw boilerplate; merged carriers keep only the real prior-tail
content.

Tests: two new cases against the real FastAPI app (self.client) —
scaffolding-stripped-from-messages (asserts hidden/merged/live rows
render correctly and the raw marker text never appears), and
hidden-row-preserves-pagination-count. A third test locks in the
export scope decision as a regression guard (export keeps full
carrier fidelity). Mutation-verified: stashed the production fix, the
scaffolding-stripping test failed showing the exact raw
"[CONTEXT COMPACTION — REFERENCE ONLY]..." text leaking; the
pagination-count test correctly still passed (count is
fix-independent, a legitimate negative control).

Caught during test-writing: my first draft put reasoning_content on a
role="user" row — SessionDB only persists reasoning_content/
codex_reasoning_items for role="assistant" rows (verified directly
against SessionDB), which would have made those specific assertions
vacuous regardless of the fix. Moved to role="assistant", matching how
merged/standalone carriers actually arrive in practice.

167 passed, 1 pre-existing skip in the full test_web_server.py file.
Could not run the sibling tests/gateway/test_api_server_compaction_
projection.py (same projection mechanism, different surface) in this
sandbox — aiohttp lives under optional [messaging]/[slack]/[matrix]
extras, not [dev], and isn't installed here; import fails at
collection, unrelated to this change. ruff clean. Full web_server app
still imports cleanly (311 routes).
@alt-glitch alt-glitch added type/bug Something isn't working comp/dashboard Web dashboard / control panel UI (dashboard/, landing) area/compression Context compression and continuation sessions P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 21, 2026
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference; please use your judgment.

  • hermes_cli/web_routers/sessions.py:25-27 — this imports two private names (_COMPACTION_INTERNAL_FIELDS, project_compaction_message_for_display) and then re-implements the hidden-row fallback locally; the "what counts as internal scaffolding" knowledge now lives in three places (the projector, this constant-pop loop, and api_server's _project_client_message). Expose one public function in agent.compaction_display that does project-or-hidden and call it here, so future field additions can't silently leak through a stale local copy.
  • hermes_cli/web_routers/sessions.py:60-66 — the is None branch blanks any message the projector declines, labeling it display_kind="hidden"; if the projector's None contract ever widens (corrupt carriers, unknown shapes), this endpoint will silently empty those rows too, with no signal. Either narrow the fallback (only blank when the message matches known handoff markers) or log at debug level so operators can diagnose "disappeared" dashboard rows.
  • hermes_cli/web_routers/sessions.py:670-675 — only _read() projects; sibling dashboard surfaces that touch message content (session-list previews/search snippets, any streaming messages route) will keep serving raw handoff boilerplate, so the fix is inconsistent within the same UI. Audit the other response paths in this router for the same leakage and cover them or state explicitly why they're out of scope.
  • tests/hermes_cli/test_web_server.py:2135 — nothing pins parity between this projection and gateway/platforms/api_server.py's _project_client_message despite the docstring claiming they mirror each other; a shared fixture asserting both produce identical output for the same carrier inputs would prevent the two implementations drifting apart in opposite directions.
  • tests/hermes_cli/test_web_server.py:2270 — the pagination test seeds a user-role standalone handoff but only asserts counts; also assert messages[0]["display_kind"] == "hidden" there, otherwise the hidden-row path is only exercised for assistant-role rows and a role-conditional regression would slip through. (nit)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compression Context compression and continuation sessions comp/dashboard Web dashboard / control panel UI (dashboard/, landing) P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants