mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-27 04:00:37 +00:00
Merge pull request #1923 from nesquena/stage-322
v0.51.27 — Release E1: 4-PR batch (workspace-prefix sentinel hardening, custom named provider API key resolution, streaming chat scroll-pin, Kanban detail scrollable)
This commit is contained in:
@@ -1,5 +1,43 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.51.27] — 2026-05-08 — 4-PR contributor batch (Release E1: workspace-prefix sentinel hardening, custom named provider API key resolution, streaming chat scroll-pin retention, Kanban detail scrollable)
|
||||
|
||||
### Fixed (4 PRs)
|
||||
|
||||
- **PR #1916** by @Michaelyklam — Make Kanban detail view scrollable. The app shell sets `body { overflow: hidden }`, so the Kanban main view must own vertical scrolling. Pre-fix, a selected task with a long body could push the board below the viewport with no way to reach it. Fix: add `overflow-y: auto` to `main.main.showing-kanban > #mainKanban` (one CSS property + regression test). Closes #1915.
|
||||
|
||||
- **PR #1914** by @ai-ag2026 — Keep streaming chat pinned after final render. During streaming, bottom-pinned scroll worked, but after the `done` event late Markdown layout growth could unpin the viewport — the user would see the last token, then suddenly the chat would scroll up by hundreds of pixels as render reflowed. Fix: add explicit upward-intent gating (`MESSAGE_UPWARD_INTENT_MS=450` ms window for wheel/touch events) so passive `scrollTop` decreases from windowing/reflow no longer count as user upward intent. Pre-replacement `shouldFollowOnDone` capture in `static/messages.js` calls `scrollToBottom()` if pin or near-bottom (`<=1200px`) was true before render. `scrollIfPinned` and `scrollToBottom` now write `_lastScrollTop` and clear the programmatic flag in a rAF so the next listener pass doesn't see a synthetic upward delta.
|
||||
|
||||
- **PR #1918** by @franksong2702 — Fix workspace prefix sentinel handling (closes #1913 follow-up filed in v0.51.25). The pre-fix strip regex `^\s*\[Workspace:[^\]]+\]\s*` was too permissive — a user prompt starting with `[Workspace: /path/to/explain]` would be silently eaten, and workspace paths containing `]` would truncate at the first `]`. Fix introduces a versioned sentinel format `[Workspace::v1: ...]` (double-colon distinguishes from natural English) AND escapes `]` in the path with `\]`. New helpers: `_workspace_context_prefix(path)`, `_escape_workspace_prefix_path(path)`, and `_strip_workspace_prefix(text, *, include_legacy=False)` with optional legacy fallback for transcript-compaction identity matching during the migration window. Closes #1913.
|
||||
|
||||
**Mid-stage absorbed fixes (per Opus advisor on stage-322):**
|
||||
1. **#1918 missed second injection site at `api/routes.py:6689`** (`_handle_chat_sync`, the `POST /api/chat` synchronous handler). Without this fix, the sync chat path would still inject legacy `[Workspace: ...]` while the streaming path injected `[Workspace::v1: ...]` — producing user bubbles that visibly leak the prefix on the sync surface, and a system-prompt format string that no longer matches reality. Maintainer routed the sync injection through `_workspace_context_prefix(...)` and updated the surrounding system-prompt text to v1 form, mirroring the streaming.py block.
|
||||
2. **#1918 backwards-compat gap in `static/ui.js:_stripWorkspaceDisplayPrefix`** — existing on-disk transcripts saved before the v1 migration still carry the legacy format. Without a JS legacy fallback, pre-upgrade sessions would render the literal `[Workspace: /tmp/proj]` prefix in user bubbles after upgrade. Maintainer added a legacy-regex fallback paralleling the Python `include_legacy=True` branch on the streaming side; updated the regression test that previously asserted the legacy regex was absent.
|
||||
|
||||
- **PR #1814** by @hualong1009 — Custom named provider API key resolution. Adds new top-level helper `resolve_custom_provider_connection(provider_id) -> (api_key, base_url)` that resolves `custom:*` provider IDs to credentials from `config.yaml > custom_providers[]`. Supports `api_key` as literal value, `${ENV_VAR}` interpolation, or `key_env` env-var hint. Uses `get_config()` snapshot (per-profile aware). Fallback to single-entry `custom_providers` when slug doesn't match exactly. Also adds fallback in `api/streaming.py` self-heal paths so an agent rebuild after a transient failure can re-fetch credentials. **Deferral re-evaluated (per prior sweep notes):** the prior `maintainer-review` flag noted feared overlap with #1818, but #1818 already shipped (v0.51.19) with its slug-matching helpers. Re-checking against current master post-#1818: the new `resolve_custom_provider_connection()` is purely additive (no helper duplication). **Style observation (non-blocking)**: PR's local `_slugify` has slightly different normalization (`_` → `-`, collapse `--`, strip leading/trailing `-`) than master's canonical `_custom_provider_slug_from_name`. Internally self-consistent (both pid and entry name go through the same local slugify before comparison) so it works for matching, but a follow-up could unify the slug semantics. The 6-call-site fallback pattern (3 in `api/routes.py`, 3 in `api/streaming.py`) is also a candidate for a single `apply_custom_provider_fallback()` helper.
|
||||
|
||||
### Tests
|
||||
|
||||
4890 → **4898 collected, 4884 passing, 0 regressions** (+8 net new). Full suite ~145s on Python 3.11 (HERMES_HOME isolated). JS syntax check (`node -c`) passes on `static/messages.js` and `static/ui.js`. Browser API sanity harness (port 8789) all-green: 11 endpoints + 20 QA tests verified. Opus advisor pass: 2 BLOCKERS identified and fixed in-stage (per absorb-in-release default), then SHIP.
|
||||
|
||||
### Pre-release verification
|
||||
|
||||
- Full pytest under `HERMES_HOME` isolation: **4884 passed, 11 skipped, 1 xfailed, 2 xpassed, 8 subtests passed** in 145.18s.
|
||||
- Browser API harness against stage-322 on port 8789: all 11 endpoints + 20 QA tests PASS.
|
||||
- `node -c` on `static/messages.js`, `static/ui.js`: clean.
|
||||
- Stage diff: 13 files, +348/-22 (pre-Opus-fix); 14 files, +382/-31 (post-Opus-fix incorporating the routes.py legacy-injection fix and ui.js legacy-fallback fix).
|
||||
- Opus advisor pass on stage-322 brief: identified 2 BLOCKERS in PR #1918 (missed `routes.py` injection site + missing JS legacy fallback). Both absorbed in-stage per absorb-in-release default. Test that asserted "legacy regex absent" updated to assert legacy regex IS present (mirrors Python `include_legacy=True` branch).
|
||||
- v0.51.26 fixes verified preserved across rebase: `_strip_workspace_prefix` (10), `on_interim_assistant` (2), `_max_iterations_cfg` (9), `if input_tokens > 0:` (1), `get_default_hermes_root` (3), `_sessionSegmentCount` (9), `_active_skills_dir` (6).
|
||||
- Pre-stamp re-fetch of all 4 PR heads: no contributor force-pushes during the Opus window.
|
||||
|
||||
### Opus-applied fixes (absorbed in-release)
|
||||
|
||||
**From stage-322 absorption:**
|
||||
|
||||
1. **#1918 second injection site** — `api/routes.py:_handle_chat_sync` was injecting legacy `[Workspace: ...]` and telling the agent that's the active format. Fixed: routed through `_workspace_context_prefix(str(s.workspace))`; updated surrounding system-prompt strings to reference `[Workspace::v1: ...]` consistently.
|
||||
|
||||
2. **#1918 JS legacy fallback** — `static/ui.js:_stripWorkspaceDisplayPrefix` was changed to v1-only regex with no legacy fallback. Fixed: added fallthrough to legacy regex when v1 strip doesn't match, mirroring the Python `include_legacy=True` branch. Updated test `test_workspace_display_prefix_helper_strips_leading_metadata_only` to assert the legacy regex IS present (was inverted to assert it was absent).
|
||||
|
||||
## [v0.51.26] — 2026-05-08 — 5-PR follow-on contributor batch (Release D: profile-isolation hardening across cache + skills + gateway-health, context-length config-override threading, sidebar segment count UI polish)
|
||||
|
||||
### Fixed (5 PRs + 1 absorbed test)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
|
||||
>
|
||||
> Last updated: v0.51.26 (May 8, 2026) — 4890 tests collected — 5-PR Release D batch (gateway health root home, sidebar segment count, skills profile scoping, profile-home cache signature, context-length config overrides)
|
||||
> Last updated: v0.51.27 (May 8, 2026) — 4898 tests collected — 4-PR Release E1 batch (workspace-prefix sentinel hardening, custom named provider API key resolution, streaming chat scroll-pin, Kanban detail scrollable)
|
||||
> Test source: `pytest tests/ --collect-only -q`
|
||||
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
|
||||
+2
-2
@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.51.26, May 8, 2026*
|
||||
*Total automated tests collected: 4890*
|
||||
*Last updated: v0.51.27, May 8, 2026*
|
||||
*Total automated tests collected: 4898*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
@@ -1596,6 +1596,102 @@ def resolve_model_provider(model_id: str) -> tuple:
|
||||
return model_id, config_provider, config_base_url
|
||||
|
||||
|
||||
def resolve_custom_provider_connection(provider_id: str) -> tuple[str | None, str | None]:
|
||||
"""Return (api_key, base_url) for a named ``custom:*`` provider.
|
||||
|
||||
Supports ``custom_providers[].api_key`` as either a literal key or
|
||||
``${ENV_VAR}``, and ``custom_providers[].key_env`` as an env-var hint.
|
||||
Returns ``(None, None)`` when no named custom provider matches.
|
||||
"""
|
||||
pid = str(provider_id or "").strip().lower()
|
||||
if not pid.startswith("custom:"):
|
||||
return None, None
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
s = str(value or "").strip().lower().replace("_", "-").replace(" ", "-")
|
||||
while "--" in s:
|
||||
s = s.replace("--", "-")
|
||||
return s.strip("-")
|
||||
|
||||
slug = _slugify(pid.split(":", 1)[1].strip())
|
||||
if not slug:
|
||||
return None, None
|
||||
|
||||
# Read the live config snapshot to avoid stale module-level cache edge
|
||||
# cases after profile switches or runtime config edits.
|
||||
cfg_data = get_config()
|
||||
|
||||
def _resolve_key(raw_api_key, raw_key_env) -> str | None:
|
||||
api_key = None
|
||||
if raw_api_key is not None:
|
||||
key_text = str(raw_api_key).strip()
|
||||
if key_text.startswith("${") and key_text.endswith("}") and len(key_text) > 3:
|
||||
api_key = os.getenv(key_text[2:-1], "").strip() or None
|
||||
elif key_text:
|
||||
api_key = key_text
|
||||
if not api_key:
|
||||
key_env = str(raw_key_env or "").strip()
|
||||
if key_env:
|
||||
api_key = os.getenv(key_env, "").strip() or None
|
||||
return api_key
|
||||
|
||||
custom_providers = cfg_data.get("custom_providers", [])
|
||||
if not isinstance(custom_providers, list):
|
||||
custom_providers = []
|
||||
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
entry_slug = _slugify(name)
|
||||
if entry_slug != slug:
|
||||
continue
|
||||
|
||||
base_url = str(entry.get("base_url") or "").strip() or None
|
||||
api_key = _resolve_key(entry.get("api_key"), entry.get("key_env"))
|
||||
return api_key, base_url
|
||||
|
||||
# If exactly one custom provider is configured, use it as a pragmatic
|
||||
# fallback for mismatched slugs (e.g. punctuation differences).
|
||||
if len(custom_providers) == 1 and isinstance(custom_providers[0], dict):
|
||||
entry = custom_providers[0]
|
||||
return (
|
||||
_resolve_key(entry.get("api_key"), entry.get("key_env")),
|
||||
str(entry.get("base_url") or "").strip() or None,
|
||||
)
|
||||
|
||||
# Fallbacks for setups that don't use custom_providers names directly.
|
||||
providers_cfg = cfg_data.get("providers", {})
|
||||
provider_specific = providers_cfg.get(pid, {}) if isinstance(providers_cfg, dict) else {}
|
||||
provider_custom = providers_cfg.get("custom", {}) if isinstance(providers_cfg, dict) else {}
|
||||
|
||||
model_cfg = cfg_data.get("model", {})
|
||||
model_provider = str(model_cfg.get("provider") or "").strip().lower() if isinstance(model_cfg, dict) else ""
|
||||
|
||||
fallback_base = None
|
||||
for candidate in (provider_specific, provider_custom, model_cfg):
|
||||
if isinstance(candidate, dict):
|
||||
_base = str(candidate.get("base_url") or "").strip()
|
||||
if _base:
|
||||
fallback_base = _base
|
||||
break
|
||||
|
||||
fallback_key = None
|
||||
if isinstance(provider_specific, dict):
|
||||
fallback_key = _resolve_key(provider_specific.get("api_key"), provider_specific.get("key_env"))
|
||||
if not fallback_key and isinstance(provider_custom, dict):
|
||||
fallback_key = _resolve_key(provider_custom.get("api_key"), provider_custom.get("key_env"))
|
||||
if not fallback_key and isinstance(model_cfg, dict) and model_provider in {"custom", pid, slug}:
|
||||
fallback_key = _resolve_key(model_cfg.get("api_key"), model_cfg.get("key_env"))
|
||||
|
||||
if fallback_key or fallback_base:
|
||||
return fallback_key, fallback_base or None
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def model_with_provider_context(model_id: str, model_provider: str | None = None) -> str:
|
||||
"""Return the model string to pass to ``resolve_model_provider()``.
|
||||
|
||||
|
||||
+37
-13
@@ -6639,7 +6639,10 @@ def _handle_chat_sync(handler, body):
|
||||
from run_agent import AIAgent
|
||||
|
||||
with CHAT_LOCK:
|
||||
from api.config import resolve_model_provider
|
||||
from api.config import (
|
||||
resolve_model_provider,
|
||||
resolve_custom_provider_connection,
|
||||
)
|
||||
|
||||
_model, _provider, _base_url = resolve_model_provider(
|
||||
model_with_provider_context(s.model, getattr(s, "model_provider", None))
|
||||
@@ -6665,6 +6668,12 @@ def _handle_chat_sync(handler, body):
|
||||
f"[webui] WARNING: resolve_runtime_provider failed: {_e}",
|
||||
flush=True,
|
||||
)
|
||||
if isinstance(_provider, str) and _provider.startswith("custom:"):
|
||||
_cp_key, _cp_base = resolve_custom_provider_connection(_provider)
|
||||
if not _api_key and _cp_key:
|
||||
_api_key = _cp_key
|
||||
if not _base_url and _cp_base:
|
||||
_base_url = _cp_base
|
||||
agent = AIAgent(
|
||||
model=_model,
|
||||
provider=_provider,
|
||||
@@ -6677,23 +6686,24 @@ def _handle_chat_sync(handler, body):
|
||||
enabled_toolsets=_resolve_cli_toolsets(),
|
||||
session_id=s.session_id,
|
||||
)
|
||||
workspace_ctx = f"[Workspace: {s.workspace}]\n"
|
||||
workspace_system_msg = (
|
||||
f"Active workspace at session start: {s.workspace}\n"
|
||||
"Every user message is prefixed with [Workspace: /absolute/path] indicating the "
|
||||
"workspace the user has selected in the web UI at the time they sent that message. "
|
||||
"This tag is the single authoritative source of the active workspace and updates "
|
||||
"with every message. It overrides any prior workspace mentioned in this system "
|
||||
"prompt, memory, or conversation history. Always use the value from the most recent "
|
||||
"[Workspace: ...] tag as your default working directory for ALL file operations: "
|
||||
"write_file, read_file, search_files, terminal workdir, and patch. "
|
||||
"Never fall back to a hardcoded path when this tag is present."
|
||||
)
|
||||
from api.streaming import (
|
||||
_merge_display_messages_after_agent_result,
|
||||
_restore_reasoning_metadata,
|
||||
_sanitize_messages_for_api,
|
||||
_session_context_messages,
|
||||
_workspace_context_prefix,
|
||||
)
|
||||
workspace_ctx = _workspace_context_prefix(str(s.workspace))
|
||||
workspace_system_msg = (
|
||||
f"Active workspace at session start: {s.workspace}\n"
|
||||
"Every user message is prefixed with [Workspace::v1: /absolute/path] indicating the "
|
||||
"workspace the user has selected in the web UI at the time they sent that message. "
|
||||
"This tag is the single authoritative source of the active workspace and updates "
|
||||
"with every message. It overrides any prior workspace mentioned in this system "
|
||||
"prompt, memory, or conversation history. Always use the value from the most recent "
|
||||
"[Workspace::v1: ...] tag as your default working directory for ALL file operations: "
|
||||
"write_file, read_file, search_files, terminal workdir, and patch. "
|
||||
"Never fall back to a hardcoded path when this tag is present."
|
||||
)
|
||||
|
||||
_previous_messages = list(s.messages or [])
|
||||
@@ -7427,6 +7437,13 @@ def _handle_session_compress(handler, body):
|
||||
except Exception as _e:
|
||||
logger.warning("resolve_runtime_provider failed for compression: %s", _e)
|
||||
|
||||
if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"):
|
||||
_cp_key, _cp_base = _cfg.resolve_custom_provider_connection(resolved_provider)
|
||||
if not resolved_api_key and _cp_key:
|
||||
resolved_api_key = _cp_key
|
||||
if not resolved_base_url and _cp_base:
|
||||
resolved_base_url = _cp_base
|
||||
|
||||
if not resolved_api_key:
|
||||
return bad(handler, "No provider configured -- cannot compress.")
|
||||
|
||||
@@ -8041,6 +8058,13 @@ def _handle_handoff_summary(handler, body):
|
||||
except Exception as _e:
|
||||
logger.warning("resolve_runtime_provider failed for handoff summary: %s", _e)
|
||||
|
||||
if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"):
|
||||
_cp_key, _cp_base = _cfg.resolve_custom_provider_connection(resolved_provider)
|
||||
if not resolved_api_key and _cp_key:
|
||||
resolved_api_key = _cp_key
|
||||
if not resolved_base_url and _cp_base:
|
||||
resolved_base_url = _cp_base
|
||||
|
||||
if not resolved_api_key:
|
||||
summary_text = _fallback_handoff_summary(msgs)
|
||||
try:
|
||||
|
||||
+53
-9
@@ -26,6 +26,7 @@ from api.config import (
|
||||
_get_session_agent_lock, _set_thread_env, _clear_thread_env,
|
||||
SESSION_AGENT_LOCKS, SESSION_AGENT_LOCKS_LOCK,
|
||||
resolve_model_provider,
|
||||
resolve_custom_provider_connection,
|
||||
model_with_provider_context,
|
||||
)
|
||||
from api.helpers import redact_session_data, _redact_text
|
||||
@@ -586,9 +587,25 @@ def _message_text(value) -> str:
|
||||
return _strip_thinking_markup(str(value or '').strip())
|
||||
|
||||
|
||||
def _strip_workspace_prefix(text: str) -> str:
|
||||
"""Remove WebUI's model-facing workspace tag from display identity text."""
|
||||
return re.sub(r'^\s*\[Workspace:[^\]]+\]\s*', '', str(text or '')).strip()
|
||||
_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*')
|
||||
_LEGACY_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace:[^\]]+\]\s*')
|
||||
|
||||
|
||||
def _escape_workspace_prefix_path(path: str) -> str:
|
||||
return str(path or '').replace('\\', '\\\\').replace(']', '\\]')
|
||||
|
||||
|
||||
def _workspace_context_prefix(path: str) -> str:
|
||||
return f"[Workspace::v1: {_escape_workspace_prefix_path(path)}]\n"
|
||||
|
||||
|
||||
def _strip_workspace_prefix(text: str, *, include_legacy: bool = False) -> str:
|
||||
"""Remove WebUI-injected workspace tags without eating user-typed text."""
|
||||
value = str(text or '')
|
||||
stripped = _WORKSPACE_PREFIX_RE.sub('', value, count=1)
|
||||
if include_legacy and stripped == value:
|
||||
stripped = _LEGACY_WORKSPACE_PREFIX_RE.sub('', value, count=1)
|
||||
return stripped.strip()
|
||||
|
||||
|
||||
def _first_exchange_snippets(messages):
|
||||
@@ -1051,7 +1068,7 @@ def _fallback_title_from_exchange(user_text: str, assistant_text: str) -> Option
|
||||
assistant_text = _strip_thinking_markup(assistant_text or '').strip()
|
||||
if not user_text:
|
||||
return None
|
||||
user_text = re.sub(r'^\[Workspace:[^\]]+\]\s*', '', user_text)
|
||||
user_text = _strip_workspace_prefix(user_text)
|
||||
user_text = re.sub(r'\s+', ' ', user_text).strip()
|
||||
assistant_text = re.sub(r'\s+', ' ', assistant_text).strip()
|
||||
combined = f"{user_text} {assistant_text}".strip().lower()
|
||||
@@ -1443,7 +1460,7 @@ def _message_identity(msg):
|
||||
# visible optimistic bubble contains only the human text. Treat them as
|
||||
# the same turn for merge/dedup purposes; otherwise compaction results
|
||||
# render two adjacent user bubbles ("Ok" and "[Workspace...]\nOk").
|
||||
text = _strip_workspace_prefix(text)
|
||||
text = _strip_workspace_prefix(text, include_legacy=True)
|
||||
if not text and not msg.get('tool_call_id') and not msg.get('tool_calls'):
|
||||
return None
|
||||
return (
|
||||
@@ -1482,7 +1499,12 @@ def _find_current_user_turn(messages, msg_text):
|
||||
if not isinstance(msg, dict) or msg.get('role') != 'user':
|
||||
continue
|
||||
fallback = idx
|
||||
text = " ".join(_strip_workspace_prefix(_message_text(msg.get('content', ''))).split())
|
||||
text = " ".join(
|
||||
_strip_workspace_prefix(
|
||||
_message_text(msg.get('content', '')),
|
||||
include_legacy=True,
|
||||
).split()
|
||||
)
|
||||
if needle and (needle in text or text in needle):
|
||||
return idx
|
||||
return fallback
|
||||
@@ -2266,6 +2288,16 @@ def _run_agent_streaming(
|
||||
except Exception as _e:
|
||||
print(f"[webui] WARNING: resolve_runtime_provider failed: {_e}", flush=True)
|
||||
|
||||
# Named custom providers (custom:slug) may not be resolvable by
|
||||
# hermes_cli.runtime_provider directly. Fall back to config.yaml
|
||||
# custom_providers[] so WebUI can pass explicit creds/base_url.
|
||||
if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"):
|
||||
_cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider)
|
||||
if not resolved_api_key and _cp_key:
|
||||
resolved_api_key = _cp_key
|
||||
if not resolved_base_url and _cp_base:
|
||||
resolved_base_url = _cp_base
|
||||
|
||||
# Read per-profile config at call time (not module-level snapshot)
|
||||
from api.config import get_config as _get_config
|
||||
_cfg = _get_config()
|
||||
@@ -2534,15 +2566,15 @@ def _run_agent_streaming(
|
||||
|
||||
# Prepend workspace context so the agent always knows which directory
|
||||
# to use for file operations, regardless of session age or AGENTS.md defaults.
|
||||
workspace_ctx = f"[Workspace: {s.workspace}]\n"
|
||||
workspace_ctx = _workspace_context_prefix(str(s.workspace))
|
||||
workspace_system_msg = (
|
||||
f"Active workspace at session start: {s.workspace}\n"
|
||||
"Every user message is prefixed with [Workspace: /absolute/path] indicating the "
|
||||
"Every user message is prefixed with [Workspace::v1: /absolute/path] indicating the "
|
||||
"workspace the user has selected in the web UI at the time they sent that message. "
|
||||
"This tag is the single authoritative source of the active workspace and updates "
|
||||
"with every message. It overrides any prior workspace mentioned in this system "
|
||||
"prompt, memory, or conversation history. Always use the value from the most recent "
|
||||
"[Workspace: ...] tag as your default working directory for ALL file operations: "
|
||||
"[Workspace::v1: ...] tag as your default working directory for ALL file operations: "
|
||||
"write_file, read_file, search_files, terminal workdir, and patch. "
|
||||
"Never fall back to a hardcoded path when this tag is present."
|
||||
)
|
||||
@@ -2725,6 +2757,12 @@ def _run_agent_streaming(
|
||||
resolved_provider = _heal_rt.get('provider')
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = _heal_rt.get('base_url')
|
||||
if isinstance(resolved_provider, str) and resolved_provider.startswith('custom:'):
|
||||
_cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider)
|
||||
if not resolved_api_key and _cp_key:
|
||||
resolved_api_key = _cp_key
|
||||
if not resolved_base_url and _cp_base:
|
||||
resolved_base_url = _cp_base
|
||||
# Rebuild agent kwargs and create a fresh agent
|
||||
_agent_kwargs['api_key'] = resolved_api_key
|
||||
_agent_kwargs['base_url'] = resolved_base_url
|
||||
@@ -3284,6 +3322,12 @@ def _run_agent_streaming(
|
||||
resolved_provider = _heal_rt.get('provider')
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = _heal_rt.get('base_url')
|
||||
if isinstance(resolved_provider, str) and resolved_provider.startswith('custom:'):
|
||||
_cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider)
|
||||
if not resolved_api_key and _cp_key:
|
||||
resolved_api_key = _cp_key
|
||||
if not resolved_base_url and _cp_base:
|
||||
resolved_base_url = _cp_base
|
||||
# Build a fresh agent with the new credentials
|
||||
_heal_kwargs = dict(_agent_kwargs) if '_agent_kwargs' in dir() else {}
|
||||
_heal_kwargs['api_key'] = resolved_api_key
|
||||
|
||||
+6
-1
@@ -915,6 +915,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
_clearApprovalForOwner();
|
||||
_clearClarifyForOwner('terminal');
|
||||
const shouldFollowOnDone=isActiveSession&&((typeof _shouldFollowMessagesOnDomReplace==='function')
|
||||
? _shouldFollowMessagesOnDomReplace()
|
||||
: (typeof _isMessagePaneNearBottom==='function'&&_isMessagePaneNearBottom(1200)));
|
||||
if(isActiveSession){
|
||||
S.activeStreamId=null;
|
||||
}
|
||||
@@ -994,7 +997,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// No-reply guard (#373): if agent returned nothing, show inline error
|
||||
if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});}
|
||||
if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length);
|
||||
syncTopbar();renderMessages({preserveScroll:true});loadDir('.');
|
||||
syncTopbar();renderMessages({preserveScroll:true});
|
||||
if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
|
||||
loadDir('.');
|
||||
// TTS auto-read: speak the last assistant response if enabled (#499)
|
||||
if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
|
||||
}
|
||||
|
||||
+1
-1
@@ -2275,7 +2275,7 @@ main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;}
|
||||
main.main.showing-skills > #mainSkills{display:flex;}
|
||||
main.main.showing-memory > #mainMemory{display:flex;}
|
||||
main.main.showing-tasks > #mainTasks{display:flex;}
|
||||
main.main.showing-kanban > #mainKanban{display:flex;}
|
||||
main.main.showing-kanban > #mainKanban{display:flex;overflow-y:auto;}
|
||||
main.main.showing-workspaces > #mainWorkspaces{display:flex;}
|
||||
main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
main.main.showing-logs > #mainLogs{display:flex;}
|
||||
|
||||
+47
-7
@@ -70,7 +70,15 @@ function _isBacktickFenceClose(line,minLen){
|
||||
*/
|
||||
|
||||
function _stripWorkspaceDisplayPrefix(text){
|
||||
return String(text||'').replace(/^\s*\[Workspace:[^\]]+\]\s*/,'').trim();
|
||||
// v1 sentinel format `[Workspace::v1: <escaped path>]\n` injected since #1918.
|
||||
// Legacy format `[Workspace: <path>]\n` may still be present in transcripts
|
||||
// saved before the v1 migration; fall through to the legacy regex when the
|
||||
// v1 strip didn't match. Mirrors the Python `include_legacy=True` branch in
|
||||
// api/streaming.py:_strip_workspace_prefix(). Per Opus advisor on stage-322.
|
||||
const value = String(text||'');
|
||||
const stripped = value.replace(/^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*/,'');
|
||||
if(stripped !== value) return stripped.trim();
|
||||
return value.replace(/^\s*\[Workspace:[^\]]+\]\s*/,'').trim();
|
||||
}
|
||||
function _renderUserFencedBlocks(text){
|
||||
const stash=[];
|
||||
@@ -1500,7 +1508,10 @@ let _programmaticScroll=false;
|
||||
let _nearBottomCount=0;
|
||||
let _lastScrollTop=null;
|
||||
let _lastNonMessageScrollIntentMs=0;
|
||||
let _lastMessageUpwardIntentMs=0;
|
||||
let _messageUserUnpinned=false;
|
||||
const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350;
|
||||
const MESSAGE_UPWARD_INTENT_MS=450;
|
||||
function _recordNonMessageScrollIntent(e){
|
||||
const el=document.getElementById('messages');
|
||||
const target=e&&e.target;
|
||||
@@ -1510,6 +1521,18 @@ function _recordNonMessageScrollIntent(e){
|
||||
// session sidebar (or another independent pane) must not be immediately fought
|
||||
// by scrollIfPinned() writing #messages.scrollTop on the next token (#1784).
|
||||
if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now();
|
||||
else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY<0)){
|
||||
// User is intentionally moving upward in the transcript. Record the real
|
||||
// input event so later scrollTop decreases caused by layout/windowing do
|
||||
// not masquerade as user intent and strand live streaming away from bottom.
|
||||
_lastMessageUpwardIntentMs=performance.now();
|
||||
_messageUserUnpinned=true;
|
||||
_nearBottomCount=0;
|
||||
_scrollPinned=false;
|
||||
}
|
||||
}
|
||||
function _recentMessageUpwardIntent(){
|
||||
return performance.now()-_lastMessageUpwardIntentMs<MESSAGE_UPWARD_INTENT_MS;
|
||||
}
|
||||
function _recentNonMessageScrollIntent(){
|
||||
return performance.now()-_lastNonMessageScrollIntentMs<NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS;
|
||||
@@ -1533,10 +1556,11 @@ if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetS
|
||||
_scrollRaf=requestAnimationFrame(()=>{
|
||||
const top=el.scrollTop;
|
||||
const nearBottom=el.scrollHeight-top-el.clientHeight<250;
|
||||
const movedUp=_lastScrollTop!==null && top<_lastScrollTop-2;
|
||||
// scrollToBottomBtn visibility is updated below after pin state settles.
|
||||
const movedUp=_lastScrollTop!==null && top<_lastScrollTop-2 && _recentMessageUpwardIntent();
|
||||
_lastScrollTop=top;
|
||||
if(movedUp){ _nearBottomCount=0; _scrollPinned=false; } // #1731
|
||||
else { _nearBottomCount=nearBottom?_nearBottomCount+1:0; _scrollPinned=_nearBottomCount>=2; } // #1360
|
||||
if(movedUp){ _nearBottomCount=0; _scrollPinned=false; _messageUserUnpinned=true; } // #1731
|
||||
else { _nearBottomCount=nearBottom?_nearBottomCount+1:0; _scrollPinned=_nearBottomCount>=2; if(_scrollPinned) _messageUserUnpinned=false; } // #1360
|
||||
const btn=$('scrollToBottomBtn');
|
||||
if(btn) btn.style.display=_scrollPinned?'none':'flex';
|
||||
// Load older messages when scrolled near the top
|
||||
@@ -1822,16 +1846,32 @@ document.addEventListener('DOMContentLoaded',function(){
|
||||
tooltip.addEventListener('click',function(e){e.stopPropagation();});
|
||||
});
|
||||
|
||||
function _setMessageScrollToBottom(){
|
||||
const el=$('messages');
|
||||
if(!el) return;
|
||||
_programmaticScroll=true;
|
||||
el.scrollTop=el.scrollHeight;
|
||||
_lastScrollTop=el.scrollTop;
|
||||
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
||||
}
|
||||
function _isMessagePaneNearBottom(threshold=250){
|
||||
const el=$('messages');
|
||||
if(!el) return false;
|
||||
return el.scrollHeight-el.scrollTop-el.clientHeight<=threshold;
|
||||
}
|
||||
function _shouldFollowMessagesOnDomReplace(){
|
||||
return !_messageUserUnpinned && (_scrollPinned || _isMessagePaneNearBottom(1200));
|
||||
}
|
||||
function scrollIfPinned(){
|
||||
if(!_scrollPinned) return;
|
||||
if(_recentNonMessageScrollIntent()) return;
|
||||
const el=$('messages');
|
||||
if(el){_programmaticScroll=true;el.scrollTop=el.scrollHeight;setTimeout(()=>{_programmaticScroll=false;},0);}
|
||||
if(el){_programmaticScroll=true;el.scrollTop=el.scrollHeight;_lastScrollTop=el.scrollTop;requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });}
|
||||
}
|
||||
function scrollToBottom(){
|
||||
_scrollPinned=true;
|
||||
const el=$('messages');
|
||||
if(el){_programmaticScroll=true;el.scrollTop=el.scrollHeight;setTimeout(()=>{_programmaticScroll=false;},0);}
|
||||
_messageUserUnpinned=false;
|
||||
_setMessageScrollToBottom();
|
||||
const btn=$('scrollToBottomBtn');
|
||||
if(btn) btn.style.display='none';
|
||||
}
|
||||
|
||||
@@ -111,6 +111,11 @@ def test_dashboard_target_validation_allows_only_loopback_base_urls():
|
||||
def test_status_tries_default_loopback_targets_until_dashboard_found(monkeypatch):
|
||||
from api import dashboard_probe
|
||||
|
||||
# This test verifies the default auto-probe sequence. Other tests exercise
|
||||
# .env/bootstrap behavior and may leave HERMES_WEBUI_HOST at 0.0.0.0 in the
|
||||
# process env; make the default precondition explicit here.
|
||||
monkeypatch.delenv("HERMES_WEBUI_HOST", raising=False)
|
||||
|
||||
attempts = []
|
||||
|
||||
def fake_probe(host, port, timeout=0.5, scheme="http"):
|
||||
|
||||
@@ -168,10 +168,13 @@ def test_i18n_locked_string_mentions_env_var_name_in_all_locales():
|
||||
# ── Live HTTP smoke test (env var NOT set in pytest) ──────────────────────
|
||||
|
||||
|
||||
def test_get_settings_returns_password_env_var_false_when_unset():
|
||||
def test_get_settings_returns_password_env_var_false_when_unset(monkeypatch):
|
||||
"""When HERMES_WEBUI_PASSWORD is not set in the test process,
|
||||
GET /api/settings must include `password_env_var: False`."""
|
||||
# The conftest server inherits this process's env; verify it's clean.
|
||||
# Test the unset branch explicitly. Some suite neighbors intentionally set
|
||||
# HERMES_WEBUI_PASSWORD while exercising the locked-password path.
|
||||
monkeypatch.delenv('HERMES_WEBUI_PASSWORD', raising=False)
|
||||
# The conftest server inherits a sanitized env; verify this process is clean.
|
||||
assert not os.getenv('HERMES_WEBUI_PASSWORD', '').strip(), \
|
||||
'this test requires HERMES_WEBUI_PASSWORD to be unset'
|
||||
|
||||
|
||||
@@ -91,6 +91,46 @@ def test_upward_scroll_unpins_immediately_without_hysteresis():
|
||||
)
|
||||
|
||||
|
||||
def test_upward_motion_only_unpins_after_recent_user_intent():
|
||||
"""Layout/programmatic scrollTop decreases must not masquerade as user scroll-up.
|
||||
|
||||
Long-session windowing can preserve/restore scroll positions while the live
|
||||
stream is growing. If a plain scrollTop decrease always clears
|
||||
``_scrollPinned``, the viewport can be visually at bottom while the state says
|
||||
"not pinned", so streaming stops auto-following. Explicit wheel/touch upward
|
||||
input must still unpin immediately; passive layout movement must not.
|
||||
"""
|
||||
assert "let _lastMessageUpwardIntentMs=" in UI_JS, (
|
||||
"ui.js must track recent upward wheel/touch intent inside #messages so "
|
||||
"programmatic/layout scroll changes do not permanently unpin streaming."
|
||||
)
|
||||
assert "function _recentMessageUpwardIntent()" in UI_JS, (
|
||||
"ui.js must expose a recent upward transcript intent helper."
|
||||
)
|
||||
block = _scroll_listener_block()
|
||||
moved_idx = block.index("const movedUp=")
|
||||
moved_expr = block[moved_idx : block.find(";", moved_idx)]
|
||||
assert "_recentMessageUpwardIntent()" in moved_expr, (
|
||||
"movedUp must require recent wheel/touch upward intent, not only a "
|
||||
"scrollTop decrease caused by DOM/layout changes."
|
||||
)
|
||||
|
||||
|
||||
def test_wheel_touch_upward_intent_is_recorded_inside_messages():
|
||||
"""Wheel/touch gestures inside #messages must mark real upward user intent."""
|
||||
fn_start = UI_JS.index("function _recordNonMessageScrollIntent")
|
||||
fn_end = UI_JS.index("function _recentNonMessageScrollIntent", fn_start)
|
||||
fn = UI_JS[fn_start:fn_end]
|
||||
assert "_lastMessageUpwardIntentMs=performance.now()" in fn, (
|
||||
"_recordNonMessageScrollIntent must timestamp real upward transcript "
|
||||
"wheel/touch gestures before clearing _scrollPinned."
|
||||
)
|
||||
assert "e.deltaY<0" in fn and "e.type==='touchmove'" in fn, (
|
||||
"Both wheel-up and touchmove gestures inside #messages should count as "
|
||||
"user upward intent."
|
||||
)
|
||||
|
||||
|
||||
def test_downward_path_preserves_macos_momentum_hysteresis():
|
||||
"""Downward / stationary motion must still go through the original
|
||||
hysteresis re-pin path so the #1360 macOS trackpad momentum protection
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from api.streaming import (
|
||||
_fallback_title_from_exchange,
|
||||
_strip_workspace_prefix,
|
||||
_workspace_context_prefix,
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_prefix_strips_only_versioned_sentinel():
|
||||
assert _strip_workspace_prefix("[Workspace::v1: /tmp/project]\nHello") == "Hello"
|
||||
assert _strip_workspace_prefix("[Workspace: /tmp/project]\nHello") == "[Workspace: /tmp/project]\nHello"
|
||||
|
||||
|
||||
def test_workspace_prefix_escapes_paths_with_closing_brackets():
|
||||
prefix = _workspace_context_prefix("/tmp/proj-[wip]/src")
|
||||
|
||||
assert prefix == "[Workspace::v1: /tmp/proj-[wip\\]/src]\n"
|
||||
assert _strip_workspace_prefix(f"{prefix}Continue") == "Continue"
|
||||
|
||||
|
||||
def test_legacy_workspace_prefix_only_strips_for_compatibility_callers():
|
||||
legacy = "[Workspace: /tmp/project]\nContinue"
|
||||
|
||||
assert _strip_workspace_prefix(legacy) == legacy
|
||||
assert _strip_workspace_prefix(legacy, include_legacy=True) == "Continue"
|
||||
|
||||
|
||||
def test_user_typed_legacy_workspace_prefix_survives_fallback_title():
|
||||
title = _fallback_title_from_exchange(
|
||||
"[Workspace: /tmp/project]\nExplain this literal prefix",
|
||||
"Sure",
|
||||
)
|
||||
|
||||
assert title is not None
|
||||
assert title.startswith("Workspace tmp/project")
|
||||
@@ -93,6 +93,17 @@ def test_kanban_board_has_native_css_classes():
|
||||
assert "overflow-x:auto" in COMPACT_STYLE
|
||||
|
||||
|
||||
def test_kanban_main_view_scrolls_when_task_preview_is_tall():
|
||||
"""The app shell keeps body overflow hidden, so the Kanban main view
|
||||
must own vertical scrolling. Otherwise a selected task with a long body
|
||||
can push the board below the viewport with no way to reach it.
|
||||
"""
|
||||
assert re.search(
|
||||
r"main\.main\.showing-kanban\s*>\s*#mainKanban\s*\{[^}]*display:flex;[^}]*overflow-y:auto;",
|
||||
COMPACT_STYLE,
|
||||
), "Kanban main view must expose a vertical scrollbar when detail content is taller than the viewport"
|
||||
|
||||
|
||||
def test_kanban_i18n_keys_exist_in_every_locale_block():
|
||||
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
|
||||
assert len(locale_blocks) >= 8
|
||||
|
||||
@@ -418,6 +418,38 @@ class TestDoneEventSmd:
|
||||
"before renderMessages() in the 'done' handler source"
|
||||
)
|
||||
|
||||
def test_done_handler_preserves_bottom_follow_on_final_render(self):
|
||||
"""Final DOM replacement must keep auto-following users at the bottom.
|
||||
|
||||
The live stream path can be visually at bottom while _scrollPinned was
|
||||
knocked false by history/windowing/layout preservation. On `done`, the
|
||||
live DOM is replaced with persisted messages; if the handler blindly calls
|
||||
renderMessages({preserveScroll:true}) while the pin flag is false, the
|
||||
transcript can jump to the top. Capture bottom/follow intent before the
|
||||
replacement and explicitly bottom only for those users.
|
||||
"""
|
||||
fn = self.get_fn()
|
||||
assert fn, "'done' handler not found"
|
||||
assert "shouldFollowOnDone" in fn, (
|
||||
"'done' handler must capture whether the viewed transcript should "
|
||||
"continue following before replacing the live DOM."
|
||||
)
|
||||
follow_idx = fn.index("shouldFollowOnDone")
|
||||
render_idx = fn.index("renderMessages({preserveScroll:true})")
|
||||
assert follow_idx < render_idx, (
|
||||
"Follow intent must be captured before renderMessages() replaces the "
|
||||
"live transcript DOM."
|
||||
)
|
||||
after_render = fn[render_idx:render_idx + 500]
|
||||
assert "if(shouldFollowOnDone" in after_render and "scrollToBottom()" in after_render, (
|
||||
"After final render, done handler must call scrollToBottom() when the "
|
||||
"user was pinned/near-bottom before DOM replacement."
|
||||
)
|
||||
assert "_isMessagePaneNearBottom" in fn, (
|
||||
"Done follow capture must include a near-bottom DOM check, not only "
|
||||
"the possibly-stale _scrollPinned flag."
|
||||
)
|
||||
|
||||
|
||||
# ── 7. apperror event: smd parser ends cleanly ───────────────────────────────
|
||||
|
||||
|
||||
@@ -16,7 +16,14 @@ def test_workspace_display_prefix_helper_strips_leading_metadata_only():
|
||||
assert end != -1, "user fenced block renderer not found after prefix stripper"
|
||||
helper = src[start:end]
|
||||
|
||||
assert r"^\s*\[Workspace:[^\]]+\]\s*" in helper
|
||||
# v1 sentinel regex must be present (matches `[Workspace::v1: <escaped path>]`).
|
||||
assert r"^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*" in helper
|
||||
# Legacy regex must ALSO be present as a fallback for transcripts saved
|
||||
# before the v1 migration (per Opus advisor on stage-322 — without this,
|
||||
# pre-upgrade sessions render the literal `[Workspace: /path]` prefix in
|
||||
# user bubbles after upgrade). Mirrors the Python `include_legacy=True`
|
||||
# branch in api/streaming.py:_strip_workspace_prefix().
|
||||
assert r"\[Workspace:[^\]]+\]" in helper
|
||||
assert ".trim()" in helper
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user