Merge pull request #2478 from nesquena/stage-378
Release BI (stage-378): v0.51.85 — 3-PR review-bypass batch — workspace-prefix display leakage fix + release-tag update banner + Slice 3a cancel-control gate RFC
This commit is contained in:
8 files changed
+428
-33
No files matched your search
@@ -2,6 +2,17 @@
|
||||
|
||||
## [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.
|
||||
|
||||
## [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
|
||||
|
||||
+72
-11
@@ -974,6 +974,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:
|
||||
@@ -993,6 +995,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.
|
||||
|
||||
@@ -2113,6 +2136,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', '')),
|
||||
@@ -2258,10 +2283,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
|
||||
@@ -2286,11 +2316,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
|
||||
@@ -2312,7 +2345,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))
|
||||
@@ -2321,6 +2358,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
|
||||
|
||||
|
||||
@@ -3943,13 +3998,19 @@ 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.
|
||||
#
|
||||
# Only check NEW messages added by this turn — result.get('messages')
|
||||
# includes the full conversation history, so checking all of them would
|
||||
# match assistant content from prior turns and mask the real failure.
|
||||
# Keep the current-turn assistant detection aligned with the
|
||||
# display-merge logic. A compacted or replayed result payload
|
||||
# is not always a simple append-only suffix, so use the
|
||||
# workspace-aware helper from this branch while still
|
||||
# preserving the pre-turn length for downstream self-heal
|
||||
# checks introduced on master.
|
||||
_all_result_messages = result.get('messages') or []
|
||||
_prev_len = len(_previous_context_messages)
|
||||
_assistant_added = _has_new_assistant_reply(_all_result_messages, _prev_len)
|
||||
_assistant_added = _assistant_reply_added_after_current_turn(
|
||||
_all_result_messages,
|
||||
_previous_context_messages,
|
||||
msg_text,
|
||||
)
|
||||
# _token_sent tracks whether on_token() was called (any streamed text)
|
||||
if not _assistant_added and not _token_sent:
|
||||
if cancel_event.is_set():
|
||||
|
||||
+98
-12
@@ -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
|
||||
@@ -86,6 +86,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.
|
||||
|
||||
@@ -101,8 +119,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.
|
||||
@@ -145,8 +163,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'
|
||||
@@ -211,15 +229,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
|
||||
@@ -283,6 +351,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
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-2
@@ -4119,8 +4119,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 _isSafeUpdateCompareUrl(url){
|
||||
if(!url||!/^https?:\/\//i.test(url)) return false;
|
||||
|
||||
@@ -2,6 +2,7 @@ from api.models import Session
|
||||
import contextlib
|
||||
|
||||
from api.streaming import (
|
||||
_assistant_reply_added_after_current_turn,
|
||||
_context_messages_for_new_turn,
|
||||
_merge_display_messages_after_agent_result,
|
||||
_sanitize_messages_for_api,
|
||||
@@ -78,6 +79,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"},
|
||||
|
||||
@@ -182,6 +182,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."""
|
||||
@@ -718,10 +773,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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user