Skip to content

fix: prevent message duplication after context compression - #3468

Closed
jasonjcwu wants to merge 2 commits into
nesquena:masterfrom
jasonjcwu:fix/compression-replay-dedup
Closed

jasonjcwu wants to merge 2 commits into
nesquena:masterfrom
jasonjcwu:fix/compression-replay-dedup

Conversation

@jasonjcwu

Copy link
Copy Markdown
Contributor

Problem

After multiple context compression cycles, the UI transcript accumulates duplicate messages. In a session with 137 messages, 89 were duplicates from repeated history replays. The user sees old conversation fragments re-appear, and the actual new messages get buried — looking like messages are "swallowed".

Root Cause

Two issues in api/streaming.py:

1. _find_current_user_turn returns first match (not last)

When the user asks similar questions across turns (e.g., "推荐一下股票" twice), this function scans forward and returns the first matching user message. After context compression, result_messages contains the full history, so turn_candidates = result_messages[first_match:] includes the entire old conversation.

2. Merge dedup only handles 3 specific cases

The dedup loop in _merge_display_messages_after_agent_result only skips:

  • Current user turn checkpoint duplicates
  • Adjacent same assistant messages
  • Compression marker duplicates

Old messages from prior compression cycles with matching identity but not fitting these 3 cases were appended as new messages.

Fix

  1. _find_current_user_turn: Changed to return the last matching user turn instead of the first. This ensures turn_candidates only includes messages from the current turn onward.

  2. General seen-set dedup: Added a safety net check — skip any candidate whose _message_identity is already in the seen set (unless it's the current user turn, which has its own checkpoint handling).

Testing

  • All 63 existing compression/dedup/compaction tests pass
  • Manually tested with realistic multi-compression scenario (duplicate user messages across turns)
  • Verified no false dedup of legitimate identical messages in separate turns

Two fixes in api/streaming.py:

1. _find_current_user_turn: return LAST match instead of first.
   After context compression, result_messages contain the full conversation
   history. If the user asked a similar question in an earlier turn,
   first-match would return that old index, causing the merge to replay the
   entire history from that point as new messages.

2. _merge_display_messages_after_agent_result: add general seen-set dedup.
   The existing dedup only handled 3 specific cases (current user checkpoint,
   adjacent same assistant, compression marker). Old messages from prior
   compression cycles with matching identity but not fitting those 3 cases
   were appended as duplicates. Added a general check: skip any candidate
   whose identity is already in the seen set (unless it's the current user
   turn which has its own checkpoint handling).
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Reading the diff at api/streaming.py against origin/master, plus the full _merge_display_messages_after_agent_result body and the existing merge tests, I think the last-match fix is correct but the "general dedup safety net" introduces a regression in the prefix (non-compression) merge path.

The two changes do different things

Change 1 — _find_current_user_turn returning the last match (streaming.py:3023) — is well-targeted. After compression, result_messages holds the full history, and turn_candidates = result_messages[current_user_idx:] should start at the current turn. First-match anchored it at an old similar turn and replayed everything after. Last-match fixes that. Good.

Change 2 — the unconditional seen-set skip — is the problem. It lives in the shared candidate loop, so it runs in both branches, including the prefix branch:

if (
    key is not None
    and key in seen
    and not ((key == current_user_key) or is_current_user_turn)
):
    continue

seen is seeded from previous_display (streaming.py:3343), and _message_identity (streaming.py:2815) is content-based, not turn-aware — role + normalized text + tool ids. So any assistant/tool message whose text matches something already in the visible transcript gets dropped, regardless of which turn it belongs to.

Why this breaks an existing invariant

The immediately-preceding adjacent-assistant check (streaming.py:3393-3403) is deliberately adjacency-scoped, with this comment:

# Treat only adjacent identity matches as replay duplicates so
# identical answers in separate user turns remain visible.

The new block discards exactly that scoping. Trace test_same_assistant_text_across_different_turns_is_preserved (tests/test_session_save_mode.py:199):

  • previous_display = [user "first prompt", assistant "same answer"]
  • result_messages = previous_context + [user "second prompt", assistant "same answer"], msg_text="second prompt"

This takes the prefix branch (_messages_have_prefix is true). _strip_replayed_prefix finds no suffix/prefix overlap (candidate prefix is user "second prompt"), so candidates = [user "second prompt", assistant "same answer"]. The user turn is the current turn (kept). The trailing assistant "same answer" has key in seen (it matches the first turn's answer) and is neither the current user key nor a current user turn — so the new block continues and drops it. Expected [..., "second prompt", "same answer"] becomes [..., "second prompt"].

So I'd expect this test (and likely test_context_message_dedup.py:39's sibling assertions about identical answers) to fail. The PR body says all 63 pass — worth re-running tests/test_session_save_mode.py::test_same_assistant_text_across_different_turns_is_preserved specifically, since that's the one the safety net collides with.

Recommendation

After the last-match fix, turn_candidates already excludes old history, so the only pre-current-turn candidates are marker_candidates (compression markers, already deduped at streaming.py:3405). The general net is mostly redundant in the else branch and actively harmful in the prefix branch. Two options:

  1. Scope the net to the compression branch only, and only to the pre-current-turn region — i.e. apply it to marker_candidates/history slices, not to the tail at/after current_user_idx. That kills replayed history without touching legitimate separate-turn repeats.
  2. Track origin explicitly: compute prev_display_keys = set(seen) once before the loop and only skip a candidate if its index falls before the current-turn boundary and its key was in prev_display_keys. Newly-seen-this-merge identities (the actual current-turn answer) then survive.

A regression test mirroring test_same_assistant_text_across_different_turns_is_preserved but routed through a post-compression replay (full history in result_messages, repeated answer in the current turn) would lock both behaviors down at once.

The general seen-set safety net in the candidate loop was running in both
the prefix (normal append) and compression branches. In the prefix branch,
it could drop a legitimate second assistant message whose text happened to
match an earlier turn's answer — breaking the invariant that identical
answers in separate user turns remain visible.

After the last-match fix to _find_current_user_turn, turn_candidates already
starts at the current turn, so old history is excluded from the delta. The
general net is no longer needed.

Thanks to @nesquena-hermes for the detailed review.
@jasonjcwu

Copy link
Copy Markdown
Contributor Author

Great catch, thank you. You're right — the general seen-set net was breaking the prefix branch invariant.

I reproduced the exact failure you described:

previous_display = [user "first prompt", assistant "same answer"]
result_messages = previous_context + [user "second prompt", assistant "same answer"]
# Got 3 messages — second "same answer" dropped

Removed the over-broad safety net in 59f36a2. The last-match fix to _find_current_user_turn already ensures turn_candidates starts at the current turn, so old history is excluded from the delta without needing a general net.

All 81 tests pass including test_same_assistant_text_across_different_turns_is_preserved and the compression replay scenario.

nesquena-hermes added a commit that referenced this pull request Jun 3, 2026
## Release v0.51.235 — Release HC (stage-q5)

Single fix in the data-sensitive transcript-merge path, with a Codex-found MUST-FIX applied.

### Fix
| PR | Author | Fix |
|----|--------|-----|
| #3468 | @jasonjcwu | `_find_current_user_turn` returns the **last** matching user turn instead of the first, so post-compression `result_messages` (which carries the full history) no longer replays the entire conversation when the user repeats a similar question (137-msg session → 89 duplicate replays). |

### Pre-release review caught a CORE issue (fixed before ship)
Codex's regression gate found that a naive last-match could be overridden by a **synthetic `role:"user"` continuation prompt** (the agent loop injects "Continue"/empty-recovery nudges — verified at `conversation_loop.py:1763/4183/4356`) that only *substring*-matches the user text — anchoring the merge **past** the real turn and dropping the assistant/tool output in between. Applied the fix: track **strong** (exact `_looks_like_current_user_turn`) and **weak** (substring) matches separately and return `last_strong → last_weak → fallback`. The real turn (strong) always wins over a later synthetic continuation (weak).

Opus reviewed the original and said ship; the applied fix is strictly safer than what it reviewed.

### Gate results
- **Full pytest suite**: 7465 passed, 8 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN
- **Codex (regression)**: SHIP ONLY WITH FIXES → fix applied → re-reviewed **SAFE TO SHIP**
- **Opus (correctness)**: SHIP IT (on the pre-fix code; applied fix is strictly safer)
- **Regression test** (`tests/test_issue3468_duplicate_after_compression.py`, 7 cases): pins the last-match behavior, the strong-beats-later-weak invariant, and the end-to-end no-duplicate-replay invariant — each **verified to fail against the pre-fix logic**.

Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.235 (Release HC) via release PR #3490 — thank you @jasonjcwu! 🙏

Your fix to _find_current_user_turn (return the last matching user turn so post-compression result_messages doesn't replay the whole conversation history) is now on master and tagged.

One adjustment absorbed on the way in: the pre-release Codex regression gate found that a naive last-match could be overridden by a synthetic role:"user" continuation prompt (the agent loop injects "Continue"/empty-recovery nudges after the real turn — conversation_loop.py:1763/4183/4356) that only substring-matches the user text, which would anchor the merge past the real turn and drop the assistant/tool output in between. So the merged-in version tracks strong (exact _looks_like_current_user_turn) and weak (substring) matches separately and returns last_strong → last_weak → fallback — the real turn always wins over a later synthetic continuation. A 7-case regression test pins the behavior (verified to fail against the pre-fix logic).

Note: the PR body also described a "general seen-set dedup" change, but that wasn't in the committed diff — only the _find_current_user_turn change shipped. If you intended that second change, feel free to open a follow-up.

Authorship preserved via Co-authored-by and credited in the CHANGELOG. Closing as merged-via-release-stage (the change is in master, recommitted on the stage branch rather than via this branch directly).

nesquena-hermes added a commit that referenced this pull request Jun 3, 2026
…inst live worker state (#3492)

## Release v0.51.237 — Release HE (stage-q8)

Phase 3 MEDIUM-ring pick (3-factor framework): a real concurrency/state-consistency fix from a regular contributor (@franksong2702, ★★★ 38 merges). The hardest review of the sweep — the dual gate caught **two** silent data-loss bugs across two review rounds.

### Fixed
| PR | Author | Fix |
|----|--------|-----|
| #3475 | @franksong2702 | Cancelling a live turn immediately after send now reliably stops the worker and settles the session to a cancelled state (was: spinner over a blank page). `cancel_stream()` falls back to the live active-run registry (`ACTIVE_RUNS`) + session agent cache when `STREAMS` has already detached, so the worker still receives `interrupt("Cancelled by user")`. `/api/session` reports run-journal active state from the live registry instead of trusting a persisted `active_stream_id`. |

### Pre-release dual gate caught TWO silent data-loss bugs (both fixed + regression-tested)
The PR's refactor moved `agent.interrupt()` ahead of the partial-text/reasoning/tool-call snapshot, and that snapshot was no longer under `streams_lock`:
1. **Codex round 1** — the worker's `finally` (which pops `STREAM_PARTIAL_TEXT`/`STREAM_REASONING_TEXT`/`STREAM_LIVE_TOOL_CALLS` under `STREAMS_LOCK`) could clear those buffers the instant `interrupt()` wakes it, so a cancelled turn **silently lost its already-streamed text**. (Opus reviewed the original and said ship — it assumed the snapshot was still lock-protected; the stale comment claimed so but the code wasn't. Verified against the actual code → Codex was right.)
2. **Codex round 2** — my first fix only snapshotted on the `STREAMS`-present path; the detached `ACTIVE_RUNS`-only path (the case this PR adds) still lost text. Fixed by hoisting the snapshot above the `if stream_present` branch so it runs unconditionally under the lock.

Both fixes have regression tests **verified to fail against the buggy versions** (`test_cancel_preserves_partial_text_when_interrupt_pops_buffers` + `test_cancel_preserves_partial_text_on_detached_active_run_path`).

### Gate results
- **Full pytest suite**: 7481 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN
- **Codex (regression)**: SHIP ONLY WITH FIXES (×2 rounds) → both MUST-FIXes applied → re-reviewed **SAFE TO SHIP**
- **Opus (correctness)**: reviewed the original (SAFE); the shipped version is strictly safer (adds the under-lock snapshot Opus deemed unnecessary)
- Deadlock concern cleared: no path takes `ACTIVE_RUNS_LOCK` then `STREAMS_LOCK`.

Rebased onto current master (streaming.py/routes.py merged clean — no overlap with the #3468 dedup change shipped in HC).

Closes #3475.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
eleboucher pushed a commit to eleboucher/homelab that referenced this pull request Jun 4, 2026
…➔ 0.51.252) (#813)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/nesquena/hermes-webui](https://github.com/nesquena/hermes-webui) | patch | `0.51.230` → `0.51.252` |

---

### Release Notes

<details>
<summary>nesquena/hermes-webui (ghcr.io/nesquena/hermes-webui)</summary>

### [`v0.51.252`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051252--2026-06-03--Release-HT-stage-q24--selection-bleed-fix--compatibility-docs)

[Compare Source](nesquena/hermes-webui@v0.51.251...v0.51.252)

##### Fixed

- The floating "selected-text reply" button no longer lets its own label get caught in a text selection (`user-select:none`), so dragging a selection near the button doesn't bleed into it. ([#&#8203;2481](nesquena/hermes-webui#2481), [@&#8203;rodboev](https://github.com/rodboev))

##### Docs

- README now has a **Compatibility** section documenting that the WebUI is tested against the matching hermes-agent release and that both should be upgraded together (until the stable agent API [#&#8203;2491](nesquena/hermes-webui#2491) lands). ([@&#8203;rodboev](https://github.com/rodboev))

### [`v0.51.251`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051251--2026-06-03--Release-HS-stage-q23--composer--path-autocomplete)

[Compare Source](nesquena/hermes-webui@v0.51.250...v0.51.251)

##### Fixed

- Typing a `~/` path token in the composer (e.g. `check this file ~/`) now opens a home-directory path-suggestion dropdown, matching the TUI's path completion. It reuses the existing slash-command dropdown (positioning + keyboard nav) and the server's trusted `/api/workspaces/suggest` endpoint, and only replaces the matched path token on selection (surrounding message text is preserved). Slash-command autocomplete still takes precedence for `/`-prefixed input. ([#&#8203;3433](nesquena/hermes-webui#3433), [@&#8203;puneetdixit200](https://github.com/puneetdixit200))

### [`v0.51.250`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051250--2026-06-03--Release-HR-stage-q22--Zeus-appearance-skin)

[Compare Source](nesquena/hermes-webui@v0.51.249...v0.51.250)

##### Added

- New **Zeus** appearance skin (Settings → Appearance, or `/theme skin zeus`) — OLED-near-black dark surfaces that keep the default gold accent, for a high-contrast "gold on black" look that no existing skin offered. All visual changes are scoped to `data-skin="zeus"`; it's dark-focused and falls back to the default light palette in light mode. ([#&#8203;3328](nesquena/hermes-webui#3328), [@&#8203;heagandev](https://github.com/heagandev))

### [`v0.51.249`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051249--2026-06-03--Release-HQ-stage-q21--auto-expand-terminal-on-output-toggle)

[Compare Source](nesquena/hermes-webui@v0.51.248...v0.51.249)

##### Added

- New **"Auto-expand terminal on output"** preference (Settings → Preferences, **off by default**). When enabled, the collapsed embedded terminal panel surfaces itself automatically the first time a running command emits output, so long-running command output isn't silently collected behind a collapsed panel. The auto-expand does not steal focus from the composer, and fires once per stream (not per output chunk). Mirrors the existing `simplified_tool_calling` setting pattern; default-off means no behavior change on upgrade. ([#&#8203;2974](nesquena/hermes-webui#2974), [@&#8203;rodboev](https://github.com/rodboev))

### [`v0.51.248`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051248--2026-06-03--Release-HP-stage-q20--self-heal-deleted-WebUI-sessions-instead-of-bricking-the-chat)

[Compare Source](nesquena/hermes-webui@v0.51.247...v0.51.248)

##### Fixed

- A WebUI session whose sidecar was deleted server-side (e.g. after `docker compose --force-recreate`) but whose messages still live in `state.db` no longer **bricks the chat** — it looked alive (`GET /api/session` returned 200 from a synthesized CLI stub) while every action failed (`POST /api/session/draft` and `/api/chat/start` returned 404). Now the GET handler consults `_index.json` (the canonical WebUI session registry): if the id was a WebUI-origin session (empty/`webui`/`fork` source) whose sidecar is gone, it returns 404 so the client can self-heal — clearing the saved session id and stripping the stale `/session/<id>` URL — and falls through to the welcome screen. Genuine CLI-origin sessions keep their existing read-only stub. The client self-heal now also covers the mid-session case (the current session's sidecar disappearing), not just boot. ([#&#8203;2782](nesquena/hermes-webui#2782), [@&#8203;rodboev](https://github.com/rodboev))

### [`v0.51.247`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051247--2026-06-03--Release-HO-stage-q19--coerce-reasoning-effort-to-model-supported-levels)

[Compare Source](nesquena/hermes-webui@v0.51.246...v0.51.247)

##### Fixed

- A globally-configured reasoning effort (`agent.reasoning_effort`) is now **coerced to the closest level the active model/provider actually supports** before each request, instead of being sent verbatim and rejected. For example `openai-codex` `gpt-5` rejects `max` (now degraded to `xhigh`) and `o1`/`o3`/`o4` only accept `low`/`medium`/`high` (so `max`/`xhigh` degrade to `high`). Coercion only ever steps *down* to a supported level (never escalates), and `none`/unset are preserved. The model/provider effort-capability filter is applied consistently across the heuristic, models.dev metadata, GitHub Copilot, and LM Studio detection paths. ([#&#8203;3505](nesquena/hermes-webui#3505), [@&#8203;franksong2702](https://github.com/franksong2702))

### [`v0.51.246`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051246--2026-06-03--Release-HN-stage-q18--WebUI-rename-syncs-to-agent-statedb)

[Compare Source](nesquena/hermes-webui@v0.51.245...v0.51.246)

##### Fixed

- Renaming a session in the WebUI now writes the new title through to the agent's `state.db`, so the TUI and CLI no longer keep showing the old name. The `/api/session/rename` handler now calls `_sync_session_title_to_insights()` (gated on the `sync_to_insights` setting) — exactly like the sibling `/api/session/title/regenerate` handler already did. ([#&#8203;3225](nesquena/hermes-webui#3225), [@&#8203;rodboev](https://github.com/rodboev))

### [`v0.51.245`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051245--2026-06-03--Release-HM-stage-q17--messaging-source-badge-in-chat-topbar)

[Compare Source](nesquena/hermes-webui@v0.51.244...v0.51.245)

##### Fixed

- Messaging sessions (Telegram, Discord, WeChat, etc.) now show their platform source badge in the **chat-pane topbar**, not just the sidebar. The topbar badge was gated on `is_cli_session`, which is intentionally `false` for messaging sources, so the badge silently disappeared once you opened the session. The gate is removed; a recovered native session whose sidecar stamps `source_label: "WebUI"` is still left un-badged (it isn't a foreign source). ([#&#8203;3338](nesquena/hermes-webui#3338), [@&#8203;rodboev](https://github.com/rodboev))

### [`v0.51.244`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051244--2026-06-03--Release-HL-stage-q16--workspace-OS-import-drop--composer-drop-zone-polish)

[Compare Source](nesquena/hermes-webui@v0.51.243...v0.51.244)

##### Added

- **Drop OS files/folders onto a specific workspace folder row or breadcrumb segment** to upload into that directory (not only the current directory). OS folder drops are traversed via `webkitGetAsEntry`/`readEntries` and their nested structure is preserved on upload. Composer `@path` drags ([#&#8203;1097](nesquena/hermes-webui#1097)), the internal tree-move ([#&#8203;3402](nesquena/hermes-webui#3402)), and OS-drop isolation ([#&#8203;3411](nesquena/hermes-webui#3411)) are all preserved. ([#&#8203;3402](nesquena/hermes-webui#3402), [#&#8203;3424](nesquena/hermes-webui#3424), [@&#8203;pamnard](https://github.com/pamnard))

##### Fixed

- The composer drop-zone overlay no longer looks garbled when you drag a workspace file (or OS file) over the footer. Previously the translucent overlay let the textarea, attach/mic icons, and model/profile chips bleed through and collide with the hint text. The overlay is now a clean, fully-opaque box with a single centered, context-aware label — **"Drop to insert workspace reference"** when dragging a workspace file (which inserts an `@path` reference) vs **"Drop files to attach"** for an OS file (which attaches it to the message).

### [`v0.51.243`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051243--2026-06-03--Release-HK-stage-q15--drag-to-move-files-within-the-workspace)

[Compare Source](nesquena/hermes-webui@v0.51.242...v0.51.243)

##### Added

- You can now **drag a file or folder in the workspace tree onto another folder row (or a breadcrumb segment) to move it** within the workspace. A new `POST /api/file/move` performs the move server-side, confined to the workspace root (`safe_resolve` on both source and destination, rejects `..` destinations, and refuses to move a folder into itself or a descendant). Name collisions and no-op moves are handled, and the drop handlers use `stopPropagation` so the existing composer `@path` drag ([#&#8203;1097](nesquena/hermes-webui#1097)) and OS-file upload-on-drop ([#&#8203;3411](nesquena/hermes-webui#3411)) are unchanged. ([#&#8203;3402](nesquena/hermes-webui#3402), [#&#8203;3422](nesquena/hermes-webui#3422), [@&#8203;pamnard](https://github.com/pamnard))

### [`v0.51.242`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051242--2026-06-03--Release-HJ-stage-q14--Graphite-skin)

[Compare Source](nesquena/hermes-webui@v0.51.241...v0.51.242)

##### Added

- New **Graphite** appearance skin — a quiet, neutral-gray "workbench" alternative to the default gold/cream, selectable from Settings → Appearance (and `/theme skin graphite`). All visual changes are scoped to `data-skin="graphite"` so the default appearance is unchanged; the skin ships both light and dark palettes built on the existing CSS-variable token system (no new dependency or build step). Tightens typography, shadows, active-sidebar spacing, and code-block framing, and uses a neutral gray palette rather than an olive-tinted one. ([#&#8203;3440](nesquena/hermes-webui#3440), [@&#8203;t3chn0pr13st](https://github.com/t3chn0pr13st))

### [`v0.51.241`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051241--2026-06-03--Release-HI-stage-q13--New-Chat-returns-to-your-unsent-draft-after-visiting-history)

[Compare Source](nesquena/hermes-webui@v0.51.240...v0.51.241)

##### Fixed

- Starting a **New Chat** draft, peeking at a previous conversation, then clicking **New Chat** again no longer loses your unsent prompt. Zero-message New Chat sessions are intentionally hidden from the sidebar, so after you navigated away there was no way back to the empty session that held your draft — New Chat just created another fresh empty session and the draft was stranded. The New Chat entrypoint now remembers the candidate empty draft session (a single `localStorage` pointer) and, before creating a fresh session, re-validates it through `/api/session` and routes back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, and a non-empty server-side `composer_draft`). The composer draft is also flushed to the server before a session switch so typing and immediately navigating away can't drop it. Clearing the draft (e.g. after sending) clears the pointer, so an emptied draft never traps you on New Chat. ([#&#8203;3333](nesquena/hermes-webui#3333), [#&#8203;3471](nesquena/hermes-webui#3471), [@&#8203;starGazerK](https://github.com/starGazerK))

### [`v0.51.240`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051240--2026-06-03--Release-HH-stage-q12--mobile-swipe-up-stops-streaming-auto-scroll)

[Compare Source](nesquena/hermes-webui@v0.51.239...v0.51.240)

##### Fixed

- On mobile/touch devices you can now swipe up to stop the auto-scroll-during-streaming behavior. Previously the stream snapped back to the bottom on every token and there was no way to read earlier content while a response was arriving: `_recordNonMessageScrollIntent()` only detected upward intent on the wheel path (`typeof e.deltaY === 'number'`), but touch events carry no `deltaY`, so a finger swipe never unpinned the view. The handler now tracks the `touchstart` Y position and treats a `touchmove` that moves the finger up by >8px as upward-scroll intent — the same authoritative unpin (`_messageUserUnpinned`) the wheel path uses — so auto-follow stops until you scroll back to the bottom or tap the ↓ button. ([#&#8203;3470](nesquena/hermes-webui#3470), [@&#8203;cnogrin](https://github.com/cnogrin))

### [`v0.51.239`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051239--2026-06-03--Release-HG-stage-q10--ignore-SIGPIPE-so-a-dropped-client-cant-kill-the-server)

[Compare Source](nesquena/hermes-webui@v0.51.238...v0.51.239)

##### Fixed

- The server no longer dies silently when a client drops the connection mid-response. Python's default action for `SIGPIPE` is `Term`, so a single broken-pipe `socket.send()` in any `ThreadingHTTPServer` worker thread (browser tab closed mid-stream, network drop, mobile backgrounding, a dropped long-poll, an `/api/updates/check` timeout) could terminate the entire WebUI process — no exception, no log, no `/health` response. `server.py` now sets `SIGPIPE` to `SIG_IGN` at import time: the kernel surfaces the broken pipe as a catchable `BrokenPipeError`, the per-request handler unwinds, the connection closes, and the server keeps serving. The handler is `getattr`-guarded so it is a no-op on Windows, where `SIGPIPE` does not exist (preserves native-Windows support, [#&#8203;1952](nesquena/hermes-webui#1952)) (salvaged from [#&#8203;3407](nesquena/hermes-webui#3407), [@&#8203;PatrickNoFilter](https://github.com/PatrickNoFilter)).

### [`v0.51.238`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051238--2026-06-03--Release-HF-stage-q9--New-Conversation-hits-the-fast-path-on-cold-start)

[Compare Source](nesquena/hermes-webui@v0.51.237...v0.51.238)

##### Fixed

- Clicking **New Conversation** on a cold start no longer hangs for 3–4s on a catalog rebuild. `POST /api/session/new`'s fast path (`_resolve_compatible_session_model_state`) returns immediately only when the request carries both a `model` and a truthy `model_provider`; on a cold/unhydrated dropdown the client sent `model_provider=null`, so the request fell into `get_available_models()` and rebuilt the full catalog (the "first click slow, later clicks fast" asymmetry from [#&#8203;2518](nesquena/hermes-webui#2518)). `newSession()` (`static/sessions.js`) now falls back to `window._activeProvider` (then the previous session's `model_provider`) when the dropdown option carries no provider, so the first click takes the fast path too. **Two guards keep this safe:** (1) a slash-qualified (`gemini/…`) or `@provider:model` slug already carries a foreign provider namespace from a prior backend, so the fallback deliberately leaves `model_provider=null` for those; (2) even a *bare* model can carry a known family prefix (`gpt`→openai, `claude`→anthropic, `gemini`→google) — if that family maps to a different provider than the fallback we'd attach, `model_provider` is left null too. Both cases preserve the server slow-path's family-aware cross-provider repair rather than silently re-pointing the new session at the wrong backend ([#&#8203;2518](nesquena/hermes-webui#2518) follow-up, [@&#8203;franksong2702](https://github.com/franksong2702)).

### [`v0.51.237`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051237--2026-06-03--Release-HE-stage-q8--reconcile-early-cancel-against-live-worker-state)

[Compare Source](nesquena/hermes-webui@v0.51.236...v0.51.237)

##### Fixed

- Cancelling a live turn immediately after sending now reliably stops the worker and settles the session to a cancelled state, instead of leaving the UI showing a running spinner over a blank session page. The bug was an early-cancel race: the browser SSE could detach (removing the entry from `STREAMS`) before the worker was fully reflected there, so `cancel_stream()` returned early and never interrupted the agent. `cancel_stream()` now falls back to the live active-run registry (`ACTIVE_RUNS`) and the session agent cache when `STREAMS` has already detached, so the worker still receives `interrupt("Cancelled by user")` and the session is cleaned up. Relatedly, `/api/session` now reports run-journal active state from the live active-run registry rather than treating any persisted `active_stream_id` as proof the worker is still alive ([#&#8203;3475](nesquena/hermes-webui#3475), [@&#8203;franksong2702](https://github.com/franksong2702)).

### [`v0.51.236`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051236--2026-06-03--Release-HD-stage-q7--native-Windows-support-for-bootstrap-and-terminal)

[Compare Source](nesquena/hermes-webui@v0.51.235...v0.51.236)

##### Added

- Native Windows support for `bootstrap.py` and the embedded terminal ([#&#8203;1952](nesquena/hermes-webui#1952)). Hermes WebUI already ran on Windows when invoked as `python server.py` directly; this unblocks the supported `python bootstrap.py` path. `api/terminal.py` no longer hard-imports the POSIX-only `fcntl`/`termios`/`select` at module load — they're guarded behind `_TERMINAL_SUPPORTED = sys.platform != "win32"`, and the embedded-terminal entry points raise `NotImplementedError` (or no-op) on Windows, following the existing optional-feature guard pattern (`api/turn_journal.py`, `api/providers.py`). The bootstrap native-Windows block becomes a warning instead of a hard `RuntimeError`; auto-install (which shells out to `/bin/bash`) still errors clearly on native Windows (WSL is unaffected), and the foreground launch path uses `subprocess.Popen` + exit on Windows (where `os.execv` spawns rather than replaces the process, orphaning it from a supervisor) instead of `os.execv`. POSIX behavior is unchanged on every path ([#&#8203;1952](nesquena/hermes-webui#1952), [@&#8203;rodboev](https://github.com/rodboev)).

### [`v0.51.235`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051235--2026-06-03--Release-HC-stage-q5--no-duplicate-transcript-replay-on-repeated-questions-after-compression)

[Compare Source](nesquena/hermes-webui@v0.51.234...v0.51.235)

##### Fixed

- The chat transcript no longer accumulates duplicate messages after multiple context-compression cycles when the user asks similar (or identical) questions across turns. `_find_current_user_turn` (`api/streaming.py`) located the slice point for the current turn's new messages by scanning `result_messages` for the user text — but after compression `result_messages` carries the full conversation history, so a *first*-match scan returned an **older** turn's index, making the merge re-append the entire replayed history from that point (observed: a 137-message session where 89 were duplicate replays, burying the real new messages). It now returns the **last** matching user turn, so the candidate slice begins at the current turn and the replayed history is not re-appended. To stay correct when the agent loop appends synthetic `role:"user"` continuation prompts (e.g. "Continue" / empty-recovery nudges) after the real turn, an exact (strong) match is preferred over a later substring (weak) match — so a synthetic continuation can't anchor the merge past the real turn and drop the assistant/tool output in between. Behavior on the no-match path (fall back to the last user index) is unchanged ([#&#8203;3468](nesquena/hermes-webui#3468), [@&#8203;jasonjcwu](https://github.com/jasonjcwu)). A regression test pins the unit behavior, the strong-beats-later-weak invariant, and the end-to-end no-duplicate-replay invariant (each verified to fail against the pre-fix logic).

### [`v0.51.234`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051234--2026-06-03--Release-HB-stage-q4--duplicate-instance-startup-guard--remote-terminal-workspace-paths)

[Compare Source](nesquena/hermes-webui@v0.51.233...v0.51.234)

##### Fixed

- The server now refuses to start when a live instance is already responding on the configured port, instead of silently sharing it (a Windows/macOS hazard where `SO_REUSEADDR` semantics let two processes bind 8787 at once, [#&#8203;3289](nesquena/hermes-webui#3289)). Rather than globally disabling `SO_REUSEADDR` (which would brick legitimate fast restarts — `ctl.sh restart` and the `os.execv` self-update path rebind immediately and would hit the TIME\_WAIT window), startup now runs a live-listener probe (`_abort_if_already_serving`): a TCP connect + `GET /health` with a 2s timeout. A live instance answers and startup aborts with a clear message; a dying instance whose socket still lingers in the kernel backlog accepts the connection but never responds, so the probe times out and startup proceeds — preserving fast restart. On Windows, `SO_EXCLUSIVEADDRUSE` is set in a `server_bind()` override to get true exclusive binding (POSIX keeps the inherited `allow_reuse_address = True`) ([#&#8203;3289](nesquena/hermes-webui#3289), [@&#8203;rodboev](https://github.com/rodboev)).
- Remote/SSH terminal profiles can now use target-side workspace paths that don't exist on the WebUI host. Workspace validation/resolution previously `stat()`-ed every path against the WebUI server's local filesystem, so a `terminal.cwd` (or session workspace) living on the remote target was rejected as nonexistent. For profiles whose terminal backend is non-local, paths **under the configured `terminal.cwd`** now pass validation without a server-local existence check, and stale server-local `last_workspace` values are ignored unless they fall under the remote cwd. Local profiles are unchanged — the bypass only fires for remote backends and only for paths contained within `terminal.cwd` ([#&#8203;3486](nesquena/hermes-webui#3486), [@&#8203;dso2ng](https://github.com/dso2ng)).

### [`v0.51.233`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051233--2026-06-03--Release-HA-stage-q3--session-truncate-keepcount-guard-against-silent-transcript-loss)

[Compare Source](nesquena/hermes-webui@v0.51.232...v0.51.233)

##### Fixed

- `POST /api/session/truncate` no longer silently wipes a session transcript on a negative `keep_count`, and no longer returns an HTTP 500 on a non-numeric one. `keep_count` fed a bare `int()` straight into the destructive `s.messages = s.messages[:keep]` slice followed by `s.save()`, so a negative value sliced as `messages[:-N]` — **deleting the most recent N messages and persisting the result to disk** (e.g. `keep_count=-5` on a 3-message session wiped the entire transcript and returned HTTP 200). `keep_count` is now validated before the slice — non-integer → `400 "keep_count must be an integer"`, negative → `400 "keep_count must be non-negative"` — mirroring the guard the sibling `/api/session/branch` handler already applies (`keep_count=0` keeps its existing "clear all messages" meaning) ([#&#8203;3472](nesquena/hermes-webui#3472), [@&#8203;Mubashirrrr](https://github.com/Mubashirrrr)).

### [`v0.51.232`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051232--2026-06-03--Release-GZ-stage-q2--cron-endpoint-query-param-guards--Japanese-locale-translations)

[Compare Source](nesquena/hermes-webui@v0.51.231...v0.51.232)

##### Fixed

- The cron output (`/api/crons/output`) and cron recent (`/api/crons/recent`) endpoints no longer return a confusing HTTP 500 on a malformed numeric query param. A non-numeric `limit` (e.g. `?limit=abc`) or `since` previously let `int()`/`float()` raise `ValueError` up to the top-level handler; both are now parsed defensively (falling back to their defaults). The cron-output `limit` is also clamped to `[1, 500]` so a negative value can't reach the newest-first `files[:limit]` slice as `files[:-n]` (which would drop the oldest entries — or return an empty list when the magnitude exceeds the count — instead of the newest outputs), mirroring the guard `_handle_cron_run_detail` already uses ([#&#8203;3473](nesquena/hermes-webui#3473), [@&#8203;Mubashirrrr](https://github.com/Mubashirrrr)).

##### Changed

- Japanese (`ja`) locale: translated 80 previously-untranslated UI strings (MCP server controls, tool summaries, and related toasts) from their English fallbacks to Japanese, with all `${…}` interpolation placeholders preserved. No locale keys added or removed ([#&#8203;3480](nesquena/hermes-webui#3480), [@&#8203;koshikai](https://github.com/koshikai)).

### [`v0.51.231`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051231--2026-06-03--Release-GY-stage-q1--model-extras-tail-resolution--plugins-tab-auto-hide--search-depth-guard--symlink-home-suggestions)

[Compare Source](nesquena/hermes-webui@v0.51.230...v0.51.231)

##### Fixed

- `/model <name>` can now select a model that lives in the **truncated `extra_models` tail** of a large provider catalog, completing the [#&#8203;3368](nesquena/hermes-webui#3368) fix that v0.51.229 left half-done. On Nous-style catalogs with >25 models the picker renders only a featured subset as `<option>` entries and pushes the rest into `extra_models`; the `/model` resolver previously matched only against the rendered `sel.options`, so a bare model living only in the extras tail (e.g. `xiaomi/mimo-v2.5` alongside the featured `xiaomi/mimo-v2.5-pro`) was un-selectable and produced a misleading "did you mean -pro?" toast. A new `_buildModelCandidates()` (`static/commands.js`) now builds the candidate set from the full `/api/models` catalog (featured `models` + `extra_models`) — the same complete list the CLI and `/model` autocomplete use — and an extras-only winner is injected via `_ensureModelOptionInDropdown()` before selection so the correct `model` + `model_provider` persist end-to-end. The [#&#8203;3437](nesquena/hermes-webui#3437) tier-guard is fully preserved: a genuinely off-catalog versioned name still refuses to snap to a `-pro`/`-flash` tier and shows the suggestion toast ([#&#8203;3368](nesquena/hermes-webui#3368), [@&#8203;nesquena-hermes](https://github.com/nesquena-hermes); with [@&#8203;garyd9](https://github.com/garyd9), confirmation [@&#8203;yutaotie](https://github.com/yutaotie)).
- The **Plugins** tab in Settings is now auto-hidden when no plugins are installed (`/api/plugins` returns `empty: true`), and deep-linking to the hidden plugins pane falls back to the Conversation section. The tab reappears automatically when plugins are detected ([#&#8203;3457](nesquena/hermes-webui#3457), [@&#8203;pix0127](https://github.com/pix0127)).
- `GET /api/sessions/search?...&depth=<x>` no longer returns a confusing HTTP 500 on a non-numeric `depth` (e.g. `?depth=deep`) and no longer silently excludes the newest messages on a negative `depth` (which sliced as `messages[:-n]`). `depth` is now parsed defensively and clamped to `>= 0` (0 keeps its existing "search the full transcript" meaning), mirroring the guard sibling handlers already use ([#&#8203;3474](nesquena/hermes-webui#3474), [@&#8203;Mubashirrrr](https://github.com/Mubashirrrr)).
- Workspace path autocomplete now expands `~/` suggestions even when the WebUI process home path is a symlink or alias of the trusted home root, so prefixes like `~/Doc` still list home-directory matches instead of returning an empty dropdown. The typed `~` target is now resolved before the trust comparison ([#&#8203;3433](nesquena/hermes-webui#3433), [@&#8203;sjh9714](https://github.com/sjh9714)).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEwMS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/813
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
## Release v0.51.235 — Release HC (stage-q5)

Single fix in the data-sensitive transcript-merge path, with a Codex-found MUST-FIX applied.

### Fix
| PR | Author | Fix |
|----|--------|-----|
| nesquena#3468 | @jasonjcwu | `_find_current_user_turn` returns the **last** matching user turn instead of the first, so post-compression `result_messages` (which carries the full history) no longer replays the entire conversation when the user repeats a similar question (137-msg session → 89 duplicate replays). |

### Pre-release review caught a CORE issue (fixed before ship)
Codex's regression gate found that a naive last-match could be overridden by a **synthetic `role:"user"` continuation prompt** (the agent loop injects "Continue"/empty-recovery nudges — verified at `conversation_loop.py:1763/4183/4356`) that only *substring*-matches the user text — anchoring the merge **past** the real turn and dropping the assistant/tool output in between. Applied the fix: track **strong** (exact `_looks_like_current_user_turn`) and **weak** (substring) matches separately and return `last_strong → last_weak → fallback`. The real turn (strong) always wins over a later synthetic continuation (weak).

Opus reviewed the original and said ship; the applied fix is strictly safer than what it reviewed.

### Gate results
- **Full pytest suite**: 7465 passed, 8 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN
- **Codex (regression)**: SHIP ONLY WITH FIXES → fix applied → re-reviewed **SAFE TO SHIP**
- **Opus (correctness)**: SHIP IT (on the pre-fix code; applied fix is strictly safer)
- **Regression test** (`tests/test_issue3468_duplicate_after_compression.py`, 7 cases): pins the last-match behavior, the strong-beats-later-weak invariant, and the end-to-end no-duplicate-replay invariant — each **verified to fail against the pre-fix logic**.

Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
…inst live worker state (nesquena#3492)

## Release v0.51.237 — Release HE (stage-q8)

Phase 3 MEDIUM-ring pick (3-factor framework): a real concurrency/state-consistency fix from a regular contributor (@franksong2702, ★★★ 38 merges). The hardest review of the sweep — the dual gate caught **two** silent data-loss bugs across two review rounds.

### Fixed
| PR | Author | Fix |
|----|--------|-----|
| nesquena#3475 | @franksong2702 | Cancelling a live turn immediately after send now reliably stops the worker and settles the session to a cancelled state (was: spinner over a blank page). `cancel_stream()` falls back to the live active-run registry (`ACTIVE_RUNS`) + session agent cache when `STREAMS` has already detached, so the worker still receives `interrupt("Cancelled by user")`. `/api/session` reports run-journal active state from the live registry instead of trusting a persisted `active_stream_id`. |

### Pre-release dual gate caught TWO silent data-loss bugs (both fixed + regression-tested)
The PR's refactor moved `agent.interrupt()` ahead of the partial-text/reasoning/tool-call snapshot, and that snapshot was no longer under `streams_lock`:
1. **Codex round 1** — the worker's `finally` (which pops `STREAM_PARTIAL_TEXT`/`STREAM_REASONING_TEXT`/`STREAM_LIVE_TOOL_CALLS` under `STREAMS_LOCK`) could clear those buffers the instant `interrupt()` wakes it, so a cancelled turn **silently lost its already-streamed text**. (Opus reviewed the original and said ship — it assumed the snapshot was still lock-protected; the stale comment claimed so but the code wasn't. Verified against the actual code → Codex was right.)
2. **Codex round 2** — my first fix only snapshotted on the `STREAMS`-present path; the detached `ACTIVE_RUNS`-only path (the case this PR adds) still lost text. Fixed by hoisting the snapshot above the `if stream_present` branch so it runs unconditionally under the lock.

Both fixes have regression tests **verified to fail against the buggy versions** (`test_cancel_preserves_partial_text_when_interrupt_pops_buffers` + `test_cancel_preserves_partial_text_on_detached_active_run_path`).

### Gate results
- **Full pytest suite**: 7481 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN
- **Codex (regression)**: SHIP ONLY WITH FIXES (×2 rounds) → both MUST-FIXes applied → re-reviewed **SAFE TO SHIP**
- **Opus (correctness)**: reviewed the original (SAFE); the shipped version is strictly safer (adds the under-lock snapshot Opus deemed unnecessary)
- Deadlock concern cleared: no path takes `ACTIVE_RUNS_LOCK` then `STREAMS_LOCK`.

Rebased onto current master (streaming.py/routes.py merged clean — no overlap with the nesquena#3468 dedup change shipped in HC).

Closes nesquena#3475.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants