From 5953c04cc4fe1a8aa24b308bdb855ff936bf21f5 Mon Sep 17 00:00:00 2001 From: swftwolfzyq <118191116+swftwolfzyq@users.noreply.github.com> Date: Tue, 12 May 2026 22:38:29 +0800 Subject: [PATCH 1/4] Fix workspace-prefixed user turn leakage --- api/streaming.py | 74 +++++++++++++-- tests/test_issue1217_transcript_compaction.py | 91 +++++++++++++++++++ 2 files changed, 156 insertions(+), 9 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index a1a8ac1b..6cdfcf61 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -633,6 +633,8 @@ def _message_text(value) -> str: _WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*') _LEGACY_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace:[^\]]+\]\s*') +_WORKSPACE_PREFIX_ANY_RE = re.compile(r'\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*') +_LEGACY_WORKSPACE_PREFIX_ANY_RE = re.compile(r'\[Workspace:[^\]]+\]\s*') def _escape_workspace_prefix_path(path: str) -> str: @@ -652,6 +654,27 @@ def _strip_workspace_prefix(text: str, *, include_legacy: bool = False) -> str: return stripped.strip() +def _looks_like_current_user_turn(msg, msg_text) -> bool: + """Match the current human turn even if an internal workspace tag leaked mid-text. + + Normal model-facing messages start with the workspace sentinel. A failed + retry/merge path can also return an optimistic draft followed by the + sentinel and the real prompt. Only treat that shape as the current turn + when the text after the sentinel exactly matches the submitted prompt. + """ + if not isinstance(msg, dict) or msg.get('role') != 'user': + return False + needle = " ".join(str(msg_text or '').split()) + if not needle: + return False + text = _message_text(msg.get('content', '')) + candidates = [_strip_workspace_prefix(text, include_legacy=True)] + for pattern in (_WORKSPACE_PREFIX_ANY_RE, _LEGACY_WORKSPACE_PREFIX_ANY_RE): + for match in pattern.finditer(text): + candidates.append(text[match.end():]) + return any(" ".join(str(candidate or '').split()) == needle for candidate in candidates) + + def _first_exchange_snippets(messages): """Return (first_user_text, first_assistant_text) snippets for title generation. @@ -1543,6 +1566,8 @@ def _find_current_user_turn(messages, msg_text): if not isinstance(msg, dict) or msg.get('role') != 'user': continue fallback = idx + if _looks_like_current_user_turn(msg, msg_text): + return idx text = " ".join( _strip_workspace_prefix( _message_text(msg.get('content', '')), @@ -1594,10 +1619,15 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex seen = {_message_identity(m) for m in merged} current_user_key = _message_identity({'role': 'user', 'content': msg_text}) current_user_in_candidates = any( - _message_identity(m) == current_user_key for m in candidates + _message_identity(m) == current_user_key or _looks_like_current_user_turn(m, msg_text) + for m in candidates ) current_user_already_checkpointed = bool( - merged and _message_identity(merged[-1]) == current_user_key + merged + and ( + _message_identity(merged[-1]) == current_user_key + or _looks_like_current_user_turn(merged[-1], msg_text) + ) ) if ( current_user_key is not None @@ -1622,11 +1652,14 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex for msg in candidates: key = _message_identity(msg) + is_current_user_turn = _looks_like_current_user_turn(msg, msg_text) if ( - key is not None - and key == current_user_key + ((key is not None and key == current_user_key) or is_current_user_turn) and merged - and _message_identity(merged[-1]) == key + and ( + _message_identity(merged[-1]) == current_user_key + or _looks_like_current_user_turn(merged[-1], msg_text) + ) ): # Eager session-save mode can checkpoint the current user turn # before the agent runs. When the agent returns that same user turn @@ -1636,7 +1669,11 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex if _is_context_compression_marker(msg) and key is not None and key in seen: continue display_msg = msg - if key is not None and key == current_user_key and isinstance(msg, dict) and msg.get('role') == 'user': + if ( + ((key is not None and key == current_user_key) or is_current_user_turn) + and isinstance(msg, dict) + and msg.get('role') == 'user' + ): display_msg = copy.deepcopy(msg) display_msg['content'] = msg_text merged.append(copy.deepcopy(display_msg)) @@ -1645,6 +1682,24 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex return merged +def _assistant_reply_added_after_current_turn(result_messages, previous_context, msg_text) -> bool: + """Return True only when the just-finished turn produced assistant text.""" + result_messages = list(result_messages or []) + previous_context = list(previous_context or []) + if _messages_have_prefix(result_messages, previous_context): + candidates = result_messages[len(previous_context):] + else: + current_user_idx = _find_current_user_turn(result_messages, msg_text) + candidates = result_messages[current_user_idx + 1:] if current_user_idx is not None else result_messages + return any( + isinstance(m, dict) + and m.get('role') == 'assistant' + and not m.get('_error') + and str(m.get('content') or '').strip() + for m in candidates + ) + + _TOOL_RESULT_SNIPPET_MAX = 4000 @@ -2782,9 +2837,10 @@ def _run_agent_streaming( # an empty final_response without raising — the stream would end with # a done event containing zero assistant messages, leaving the user with # no feedback. Emit an apperror so the client shows an inline error. - _assistant_added = any( - m.get('role') == 'assistant' and str(m.get('content') or '').strip() - for m in (result.get('messages') or []) + _assistant_added = _assistant_reply_added_after_current_turn( + result.get('messages') or [], + _previous_context_messages, + msg_text, ) # _token_sent tracks whether on_token() was called (any streamed text) if not _assistant_added and not _token_sent: diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index 7d14f124..fbe773ca 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -2,6 +2,7 @@ from api.models import Session import contextlib from api.streaming import ( + _assistant_reply_added_after_current_turn, _merge_display_messages_after_agent_result, _sanitize_messages_for_api, _session_context_messages, @@ -77,6 +78,96 @@ def test_workspace_prefixed_current_user_after_compaction_is_not_duplicated(): assert sum(1 for m in merged if m.get("role") == "user" and "Ok, mache weiter" in m.get("content", "")) == 1 +def test_embedded_workspace_prefixed_current_user_delta_is_deduped(): + """A failed provider path can echo draft text before the workspace tag.""" + current = "正常来说,chrome for testing 不是有独立的profile嘛,为什么会有 user-data-dir 冲突的问题?" + previous_display = [ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + {"role": "user", "content": "正常来说,chrome"}, + {"role": "user", "content": current}, + ] + previous_context = [ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + ] + result_messages = previous_context + [ + { + "role": "user", + "content": ( + "正常来说,chrome\n\n" + "[Workspace::v1: /mnt/e/vscode_workspace/hermes_workspace]\n" + f"{current}" + ), + }, + ] + + merged = _merge_display_messages_after_agent_result( + previous_display, + previous_context, + result_messages, + current, + ) + + assert merged == previous_display + assert all("Workspace::v1" not in str(m.get("content") or "") for m in merged) + + +def test_embedded_workspace_prefixed_current_user_delta_displays_clean_prompt(): + current = "正常来说,chrome for testing 不是有独立的profile嘛,为什么会有 user-data-dir 冲突的问题?" + previous_display = [ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + ] + previous_context = list(previous_display) + result_messages = previous_context + [ + { + "role": "user", + "content": ( + "正常来说,chrome\n\n" + "[Workspace::v1: /mnt/e/vscode_workspace/hermes_workspace]\n" + f"{current}" + ), + }, + {"role": "assistant", "content": "Chrome for Testing 本身没有固定独立 profile。"}, + ] + + merged = _merge_display_messages_after_agent_result( + previous_display, + previous_context, + result_messages, + current, + ) + + assert [m["content"] for m in merged[-2:]] == [ + current, + "Chrome for Testing 本身没有固定独立 profile。", + ] + assert all("Workspace::v1" not in str(m.get("content") or "") for m in merged) + + +def test_assistant_added_detection_ignores_prior_history(): + previous_context = [ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + ] + current = "new prompt" + result_messages = previous_context + [ + {"role": "user", "content": f"[Workspace::v1: /tmp/project]\n{current}"}, + ] + + assert not _assistant_reply_added_after_current_turn( + result_messages, + previous_context, + current, + ) + assert _assistant_reply_added_after_current_turn( + result_messages + [{"role": "assistant", "content": "new answer"}], + previous_context, + current, + ) + + def test_compacted_agent_result_keeps_old_prompts_and_appends_current_turn(): previous_display = [ {"role": "user", "content": "first prompt that must remain visible"}, From f2e5e4944263e8000487d60736ae28d5a5877aac Mon Sep 17 00:00:00 2001 From: swftwolfzyq <118191116+swftwolfzyq@users.noreply.github.com> Date: Tue, 12 May 2026 22:52:12 +0800 Subject: [PATCH 2/4] Track updates by release tags --- api/updates.py | 110 ++++++++++++++++++++++++++---- static/ui.js | 7 +- tests/test_update_banner_fixes.py | 61 ++++++++++++++++- tests/test_version_badge.py | 34 ++++++++- 4 files changed, 193 insertions(+), 19 deletions(-) diff --git a/api/updates.py b/api/updates.py index e3e025c2..746a7243 100644 --- a/api/updates.py +++ b/api/updates.py @@ -1,8 +1,8 @@ """ Hermes Web UI -- Self-update checker. -Checks if the webui and hermes-agent git repos are behind their upstream -branches. Results are cached server-side (30-min TTL) so git fetch runs +Checks if the webui and hermes-agent git repos are behind their latest +release tags. Results are cached server-side (30-min TTL) so git fetch runs at most twice per hour regardless of client count. Skips repos that are not git checkouts (e.g. Docker baked images where @@ -79,6 +79,24 @@ def _run_git(args, cwd, timeout=10): return f'git failed to start: {exc}', False +def _dirty_suffix(path: Path, timeout=1) -> str: + """Return a best-effort ``-dirty`` suffix without blocking version display.""" + out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout) + if ok: + return "" + # diff-index exits 1 with no output for a dirty tree. Timeouts and real git + # failures include a diagnostic; skip the suffix so the base version remains. + return "-dirty" if not out else "" + + +def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None: + """Return a fast git version string for a checkout, if available.""" + out, ok = _run_git(['describe', '--tags', '--always'], path, timeout=timeout) + if not (ok and out): + return None + return out + _dirty_suffix(path, timeout=dirty_timeout) + + def _detect_webui_version() -> str: """Detect the running WebUI version from git or a baked-in fallback file. @@ -94,8 +112,8 @@ def _detect_webui_version() -> str: """ # Timeout capped at 3s: git describe on a healthy local repo is <50ms; # a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable. - out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], REPO_ROOT, timeout=3) - if ok and out: + out = _describe_git_version(REPO_ROOT) + if out: return out # Docker / baked-image fallback: api/_version.py written by CI at build time. @@ -138,8 +156,8 @@ def _detect_agent_version() -> str: # Symmetric with _detect_webui_version() above — `--dirty` flags a # locally-modified checkout so operators can see when their agent has # uncommitted changes vs a clean tag. Per Opus advisor on stage-293. - out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], _AGENT_DIR, timeout=3) - if ok and out: + out = _describe_git_version(Path(_AGENT_DIR)) + if out: return out return 'not detected' @@ -194,15 +212,65 @@ def _detect_default_branch(path): return 'master' -def _check_repo(path, name): - """Check if a git repo is behind its upstream. Returns dict or None.""" - if path is None or not (path / '.git').exists(): +def _release_tags(path): + """Return release tags newest-first, using the repo's version-sort order.""" + out, ok = _run_git(['tag', '--list', 'v*', '--sort=-v:refname'], path) + if not (ok and out): + return [] + return [line.strip() for line in out.splitlines() if line.strip()] + + +def _current_release_tag(path): + """Return the latest release tag reachable from HEAD, if one exists.""" + out, ok = _run_git(['describe', '--tags', '--abbrev=0'], path) + return out if ok and out else None + + +def _release_gap(tags, current, latest): + """Count release tags between current and latest in a newest-first list.""" + if not latest or current == latest: + return 0 + if current in tags: + return tags.index(current) + return 1 + + +def _check_repo_release(path, name): + """Check if a git repo is behind its latest published release tag.""" + tags = _release_tags(path) + if not tags: return None + latest_tag = tags[0] + current_tag = _current_release_tag(path) + behind = _release_gap(tags, current_tag, latest_tag) + + remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) + remote_url = _normalize_remote_url(remote_url) + + return { + 'name': name, + 'behind': behind, + # GitHub compare URLs accept tag names, and tag-to-tag links are the + # clearest "what changed in this release?" view for operators. + 'current_sha': current_tag, + 'latest_sha': latest_tag, + 'branch': latest_tag, + 'repo_url': remote_url, + 'release_based': True, + 'current_version': current_tag, + 'latest_version': latest_tag, + } + + +def _check_repo_branch(path, name, *, fetch=True): + """Fallback: check if a git repo is behind its upstream branch.""" + # Fetch latest from origin (network call, cached by TTL) - _, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15) - if not fetch_ok: - return {'name': name, 'behind': 0, 'error': 'fetch failed'} + if fetch: + _, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15) + if not fetch_ok: + return {'name': name, 'behind': 0, 'error': 'fetch failed'} # Use the current branch's upstream tracking branch, not the repo default. # This avoids false "N updates behind" alerts when the user is on a feature @@ -265,6 +333,24 @@ def _check_repo(path, name): } +def _check_repo(path, name): + """Check if a git repo is behind its latest release. Returns dict or None.""" + if path is None or not (path / '.git').exists(): + return None + + # Fetch tags first so update prompts track published releases, not every + # development commit that lands on master/main after the latest release. + _, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags'], path, timeout=15) + if not fetch_ok: + return {'name': name, 'behind': 0, 'error': 'fetch failed'} + + release_info = _check_repo_release(path, name) + if release_info is not None: + return release_info + + return _check_repo_branch(path, name, fetch=False) + + def check_for_updates(force=False): """Return cached update status for webui and agent repos.""" global _check_in_progress diff --git a/static/ui.js b/static/ui.js index 28f94817..b3a11e5f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3773,8 +3773,11 @@ async function refreshSession() { // ── Update banner ── function _formatUpdateTargetStatus(label,info){ if(!info||!(info.behind>0)) return null; - const branch=info.branch?` (${info.branch})`:''; - return `${label}${branch}: ${info.behind} update${info.behind>1?'s':''}`; + const release=(info.release_based&&info.latest_version) + ?` (${info.current_version||'unknown'} -> ${info.latest_version})` + :(info.branch?` (${info.branch})`:''); + const noun=info.release_based?'release':'update'; + return `${label}${release}: ${info.behind} ${noun}${info.behind>1?'s':''}`; } function _showUpdateBanner(data){ const parts=[]; diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 5eaee61e..3a55796c 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -104,6 +104,61 @@ class TestUpdateChecker: assert result['repo_url'] == 'https://github.com/nesquena/hermes-webui' + def test_release_check_ignores_post_release_branch_commits(self, tmp_path, monkeypatch): + import api.updates as upd + + (tmp_path / '.git').mkdir() + + def fake_run(args, cwd, timeout=10): + if args[0] == 'fetch': + return '', True + if args[:3] == ['tag', '--list', 'v*']: + return 'v2026.5.7\nv2026.4.30', True + if args[:3] == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.7', True + if args[:2] == ['remote', 'get-url']: + return 'https://github.com/NousResearch/hermes-agent.git', True + if args[:2] == ['rev-parse', '--abbrev-ref']: + return 'origin/main', True + if args[:2] == ['rev-list', '--count']: + return '16', True + if args[0] == 'merge-base': + return '3800972dd', True + return '', False + + monkeypatch.setattr(upd, '_run_git', fake_run) + result = upd._check_repo(tmp_path, 'agent') + + assert result['release_based'] is True + assert result['current_version'] == 'v2026.5.7' + assert result['latest_version'] == 'v2026.5.7' + assert result['behind'] == 0 + + def test_release_check_counts_release_gap(self, tmp_path, monkeypatch): + import api.updates as upd + + (tmp_path / '.git').mkdir() + + def fake_run(args, cwd, timeout=10): + if args[0] == 'fetch': + return '', True + if args[:3] == ['tag', '--list', 'v*']: + return 'v0.51.35\nv0.51.34\nv0.51.33', True + if args[:3] == ['describe', '--tags', '--abbrev=0']: + return 'v0.51.34', True + if args[:2] == ['remote', 'get-url']: + return 'https://github.com/nesquena/hermes-webui.git', True + return '', False + + monkeypatch.setattr(upd, '_run_git', fake_run) + result = upd._check_repo(tmp_path, 'webui') + + assert result['release_based'] is True + assert result['current_version'] == 'v0.51.34' + assert result['latest_version'] == 'v0.51.35' + assert result['behind'] == 1 + assert result['branch'] == 'v0.51.35' + class TestConflictError: """#813 — conflict error must include flag + recovery command.""" @@ -475,10 +530,12 @@ class TestUiJsUpdateBanner: class TestUpdateBannerUx: - def test_update_banner_includes_repo_branch_labels(self): + def test_update_banner_includes_release_labels(self): src = read('static/ui.js') assert 'function _formatUpdateTargetStatus' in src - assert 'info.branch' in src + assert 'info.release_based' in src + assert 'info.current_version' in src + assert 'info.latest_version' in src assert "_formatUpdateTargetStatus('WebUI',data.webui)" in src assert "_formatUpdateTargetStatus('Agent',data.agent)" in src diff --git a/tests/test_version_badge.py b/tests/test_version_badge.py index 79fb8953..be53d5b5 100644 --- a/tests/test_version_badge.py +++ b/tests/test_version_badge.py @@ -87,8 +87,8 @@ class TestDetectWebUIVersion: ) assert result == 'unknown' - def test_git_uses_correct_describe_flags(self, tmp_path): - """git describe is called with --tags --always --dirty.""" + def test_git_uses_fast_describe_flags(self, tmp_path): + """git describe avoids --dirty so WSL /mnt checkouts do not stall.""" called_args = [] def capture(args, cwd, timeout=10): @@ -99,7 +99,35 @@ class TestDetectWebUIVersion: assert called_args, 'git was never called' assert '--tags' in called_args[0] assert '--always' in called_args[0] - assert '--dirty' in called_args[0] + assert '--dirty' not in called_args[0] + + def test_dirty_check_appends_suffix_when_fast(self, tmp_path): + """A dirty worktree still gets a suffix when the cheap probe returns quickly.""" + calls = [] + + def fake_run_git(args, cwd, timeout=10): + calls.append((args, timeout)) + if args[:3] == ['describe', '--tags', '--always']: + return ('v0.50.123', True) + if args[:2] == ['diff-index', '--quiet']: + return ('', False) + return ('unexpected', False) + + result = self._fresh_detect(mock_run_git=fake_run_git, tmp_path=tmp_path) + assert result == 'v0.50.123-dirty' + assert calls[1][0][:2] == ['diff-index', '--quiet'] + + def test_dirty_check_timeout_does_not_hide_base_version(self, tmp_path): + """If dirty detection times out, keep the base version instead of unknown.""" + def fake_run_git(args, cwd, timeout=10): + if args[:3] == ['describe', '--tags', '--always']: + return ('v0.50.123', True) + if args[:2] == ['diff-index', '--quiet']: + return ('git diff-index --quiet HEAD -- timed out after 1s', False) + return ('unexpected', False) + + result = self._fresh_detect(mock_run_git=fake_run_git, tmp_path=tmp_path) + assert result == 'v0.50.123' # --------------------------------------------------------------------------- From f2c5048741bc8e93efb86d8456efef3faff9cb88 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sun, 17 May 2026 09:58:19 -0700 Subject: [PATCH 3/4] docs(runtime): define cancel control gate --- CHANGELOG.md | 4 ++ docs/rfcs/hermes-run-adapter-contract.md | 64 ++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5086dcc..d5a549fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Documentation + +- **PR #2469** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 2 seam shipped by marking Slice 2 complete and defining the first Slice 3a cancel-control gate. The new gate scopes Stop Generation through `RuntimeAdapter.cancel_run(...)` only, pins behavior-preserving cancellation, journal/status coherence, idempotent duplicate cancel, and explicit non-goals for approval/clarify, queue/goal, runner/sidecar, and public chat-start response changes. + ## [v0.51.84] — 2026-05-17 — Release BH (stage-377 — 1-PR Docker hygiene — agent-image upgrade docs + read-only WebUI source mount + chown prune widening) ### Changed diff --git a/docs/rfcs/hermes-run-adapter-contract.md b/docs/rfcs/hermes-run-adapter-contract.md index a34f70fa..f2310d66 100644 --- a/docs/rfcs/hermes-run-adapter-contract.md +++ b/docs/rfcs/hermes-run-adapter-contract.md @@ -292,9 +292,11 @@ Success criterion: ### Slice 2: Adapter interface over the journaled legacy path -Status as of 2026-05-16: ready for a planning/adapter-seam PR after the active -Slice 1 validation pass and the #2313 selected-session stream cap. Slice 2 should -still be a reversible boundary change, not a sidecar or execution-ownership move. +Status as of 2026-05-17: shipped. PR #2416 defined the adapter-seam contract, +PR #2424 added the default-off `LegacyJournalRuntimeAdapter` seam, and PR #2438 +kept `/api/chat/start` response shape identical between `legacy-direct` and the +flagged `legacy-journal` path. Slice 2 remains a reversible boundary change, not +a sidecar or execution-ownership move. Scope: @@ -405,6 +407,13 @@ execution-survives-WebUI-restart gate remains deferred to Slice 4. ### Slice 3: Control migration +Status as of 2026-05-17: not started. Slice 3 should begin with cancel only. +Cancel is the smallest control-plane migration because it already has one clear +browser affordance, one active-run target, and an existing legacy handler to +delegate to. Approval, clarify, queue/continue, and goal are intentionally held +behind a successful cancel-control slice because they carry more callback and +state-lifetime risk. + Scope: - move cancel first, @@ -416,6 +425,55 @@ Scope: Revert path: per-control feature flags or route-level fallback to legacy control handlers. +#### Slice 3a: Cancel control gate + +The first control migration should route Stop Generation through the +`RuntimeAdapter.cancel_run(...)` seam while preserving the current legacy cancel +semantics. It should not introduce a new cancellation registry, worker-owned +signal table, or sidecar boundary. During this slice, `cancel_run` is still a +protocol translator over the existing cancellation path. + +Acceptance properties: + +1. **Same visible result as legacy Stop Generation.** A cancelled turn still + emits one terminal cancelled/interrupted state and preserves any already + streamed partial assistant content according to the existing cancellation + contract. +2. **Adapter flag is behavior-preserving.** With + `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal`, Stop Generation uses + `RuntimeAdapter.cancel_run(...)`; with the default `legacy-direct` path, the + current route remains available as fallback. +3. **No new runtime-surrogate state.** The implementation must not add a second + `CANCEL_FLAGS`-like map, cached `AIAgent` table, long-lived queue, or local + callback registry inside the main WebUI process. +4. **Journal/status coherence.** After cancellation, replay and session reload + classify the turn as cancelled/interrupted rather than stale/unknown, and the + terminal state is visible through the same journal/session diagnostic surface + used by Slice 1. +5. **Idempotent duplicate cancel.** Repeating cancel for the same run should be + safe: one terminal result is recorded, later attempts return a bounded + `ControlResult` such as `not-active` rather than creating extra terminal + events or resurrecting stale stream state. + +Suggested regression coverage: + +- a route/source test proving the flagged cancel path calls the adapter seam and + the default path remains the legacy route; +- an adapter unit test proving `cancel_run` delegates exactly once and returns a + bounded `ControlResult` for unsupported/not-active runs; +- an existing cancellation preservation suite run (for example the partial-output + and cancelled-turn status tests) under the adapter flag or an equivalent + synthetic harness; +- a replay/session-load assertion that a cancelled run's terminal state remains + classifiable from the journal/session surface after reload. + +Non-goals for Slice 3a: + +- no approval or clarify migration; +- no queue/continue or goal migration; +- no runner process, sidecar, or execution-survives-WebUI-restart claim; +- no public `/api/chat/start` response-shape expansion for adapter-only fields. + ### Slice 4: Runner process / sidecar boundary Explicitly deferred until Slice 1 has worked in production for at least one From c728de2a58a54fe610d8fcb0395c483ae31db8b3 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 17 May 2026 19:55:48 +0000 Subject: [PATCH 4/4] Stamp CHANGELOG for v0.51.85 (Release BI / stage-378 / 3-PR batch) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5a549fa..5a86a307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [v0.51.85] — 2026-05-17 — Release BI (stage-378 — 3-PR batch — workspace-prefix display leakage fix + release-tag update banner + Slice 3a cancel-control gate RFC) + +### Fixed + +- **PR #2145** by @swftwolfzyq — Prevent internal `[Workspace::v1: …]` metadata from leaking into the visible user transcript when a failed provider/retry path echoes an optimistic draft followed by the workspace prefix and the real prompt. Adds `_looks_like_current_user_turn(msg, msg_text)` to match the current human turn even when the internal tag appears mid-text — only when the text after the sentinel exactly matches the submitted prompt — and routes the merge/dedupe/display-normalization paths in `_merge_display_messages_after_agent_result` and `_find_current_user_turn` through it. Replaces `_has_new_assistant_reply` length-delta gating in `_periodic_checkpoint` with the new `_assistant_reply_added_after_current_turn` helper, which slices result messages from the current-turn position before counting assistant deltas — silent/no-response failures are now detected from the current turn alone instead of being masked by prior assistant content. +- **PR #2146** by @swftwolfzyq — Track WebUI update checks against the latest published release tag instead of every commit on the upstream branch, so operators who only want released versions stop seeing noisy update banners for post-release development commits. Falls back to branch-based detection when no release tags are available. Splits `git describe --dirty` into a fast base describe plus a bounded dirty probe so WSL-mounted workspaces never block version detection on a slow `--dirty` walk, keeping the base version visible even if the dirty probe times out. + ### Documentation - **PR #2469** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 2 seam shipped by marking Slice 2 complete and defining the first Slice 3a cancel-control gate. The new gate scopes Stop Generation through `RuntimeAdapter.cancel_run(...)` only, pins behavior-preserving cancellation, journal/status coherence, idempotent duplicate cancel, and explicit non-goals for approval/clarify, queue/goal, runner/sidecar, and public chat-start response changes.