Merge pull request #2320 from nesquena/stage-360

stage-360: 3-PR streaming-lane batch — stream completion recovery + profile-scoped aux routing + workspace panel polish (with _ENV_LOCK architectural fix)
This commit is contained in:
nesquena-hermes
2026-05-15 12:11:59 -07:00
committed by GitHub
22 changed files with 967 additions and 413 deletions
+16
View File
@@ -2,6 +2,16 @@
## [Unreleased]
### Fixed
- **PR #2279** by @franksong2702 (closes #2262 + refs #2168) — WebUI stream completion recovery gaps closed for both `notify_on_complete` background tasks and the preserved-task-list compression marker UI. Pre-fix, completions held in the agent process registry were never drained by the WebUI gateway session because the gateway session platform was unset. The fix routes the completion queue by process session key before injecting any notification into a WebUI turn. Separately, the preserved-task-list compression marker — an internal sentinel — was sometimes the only assistant text rendered after a context compression turn timed out, leaving a confusing "preserved tasks" message with no actual response. The frontend now suppresses the marker when it's the only assistant content and the run state is terminal.
- **PR #2299** by @starship-s — Background workers (title generation, manual session compression, update-summary generation) now correctly inherit profile-scoped configuration when a profile-scoped chat triggers them. Pre-fix, those workers read default-profile configuration instead of the session/request profile, so auxiliary model routing silently used the wrong configured model or failed provider resolution entirely. The fix threads the active profile context through `_run_background_title_update`, `_run_background_title_refresh`, and the manual compression and update-summary helpers, with regression tests covering all three paths.
- **PR #2306** by @dobby-d-elf (follow-up to v0.51.66) — Workspace panel header polish + test cleanup. Single close button on the workspace panel (was double in some states), tooltip now reads "Close" (was inconsistent label), `.close-preview` opacity removed so the X button matches other panel icon styling. Companion test cleanup removes ~293 lines of stale assertions in `test_issue781.py`, `test_sprint41.py`, `test_sprint44.py`, and `test_workspace_panel_session_list.py` that tested behavior either no longer present after #2238 or covered redundantly by other test files.
## [v0.51.66] — 2026-05-15 — Release AP (stage-359 — 17-PR safe-lane batch — Docker fixes + UI polish + compression snapshot improvements + i18n parity + profile validation)
### Added
- **PR #2287** by @mslovy (refs #2284) — Upload size limit is now runtime-configurable via the `HERMES_WEBUI_MAX_UPLOAD_MB` environment variable. Previously the effective 20 MB cap was hard-coded across multiple layers. Server-side upload limit moves to runtime config; browser-side preflight check stays aligned with the effective backend limit; archive extraction guard continues to scale with the same configured cap. New `_env_mb_bytes()` helper in `api/config.py` parses `HERMES_WEBUI_MAX_UPLOAD_MB`.
@@ -48,6 +58,12 @@
- **PR #2165** by @starship-s — Pooled OpenAI Codex quota status surfaced in the Providers panel. Collapsed view shows "Best of N" pool summary (available / exhausted / failed / checked counts); expandable per-credential rows. Concurrent probing capped at `min(_CODEX_POOL_MAX_WORKERS=6, len(probe_items))`. Exhausted credentials NOT re-probed during cooldown. Manual refresh = "probe now", but transient `None` probe results are NOT cached (preserves last-known-good warm snapshot); only known-exhausted snapshot objects are cached. JWT decode (`_decode_jwt_claims_unverified`) is documented as classification-only (Codex OAuth JWT vs raw OpenAI API key), explicitly NOT for authorization. Per-row plan labels only shown when verified account-limit data is available. 32-test regression suite + 11-locale i18n parity assertion.
### Fixed
- WebUI agent turns now inherit `HERMES_SESSION_PLATFORM=webui` and drain matching `notify_on_complete` background-process completions into the next model input. Completion events are filtered by the process session key before delivery, so another tab/session's background process output remains queued for its owner instead of being injected into the wrong conversation.
- Marker-only preserved-task-list compression sentinels no longer render as standalone assistant responses after stream recovery or timeout paths. If the frontend receives only that internal marker as assistant content, it replaces it with an explicit "No response received after context compression" error and shows an error toast.
## [v0.51.64] — 2026-05-14 — Release AN (stage-357 — 3-PR small batch — docker_init k8s whoami fallback + PWA manifest session routes (closes #2226) + aux title test coverage)
### Fixed
+126
View File
@@ -15,6 +15,7 @@ import re
import shutil
import sys
import threading
from contextlib import contextmanager
from pathlib import Path
from typing import Optional
@@ -42,6 +43,24 @@ _tls = threading.local()
_SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool")
def snapshot_skill_home_modules() -> dict[str, dict[str, object]]:
"""Snapshot imported skill-module path globals before a temporary patch."""
snapshot: dict[str, dict[str, object]] = {}
for module_name in _SKILL_HOME_MODULES:
module = sys.modules.get(module_name)
if module is None:
snapshot[module_name] = {"module_present": False}
continue
snapshot[module_name] = {
"module_present": True,
"has_HERMES_HOME": hasattr(module, "HERMES_HOME"),
"HERMES_HOME": getattr(module, "HERMES_HOME", None),
"has_SKILLS_DIR": hasattr(module, "SKILLS_DIR"),
"SKILLS_DIR": getattr(module, "SKILLS_DIR", None),
}
return snapshot
def patch_skill_home_modules(home: Path) -> None:
"""Patch imported skill modules that cache HERMES_HOME at import time."""
for module_name in _SKILL_HOME_MODULES:
@@ -55,6 +74,37 @@ def patch_skill_home_modules(home: Path) -> None:
logger.debug("Failed to patch %s module", module_name)
def restore_skill_home_modules(snapshot: dict[str, dict[str, object]]) -> None:
"""Restore skill-module globals captured by snapshot_skill_home_modules()."""
for module_name, values in snapshot.items():
module = sys.modules.get(module_name)
if not values.get("module_present"):
if module is not None:
sys.modules.pop(module_name, None)
parent_name, _, child_name = module_name.rpartition(".")
parent = sys.modules.get(parent_name)
if parent is not None:
try:
delattr(parent, child_name)
except AttributeError:
pass
continue
if module is None:
continue
for attr in ("HERMES_HOME", "SKILLS_DIR"):
has_attr = bool(values.get(f"has_{attr}"))
try:
if has_attr:
setattr(module, attr, values.get(attr))
else:
try:
delattr(module, attr)
except AttributeError:
pass
except AttributeError:
logger.debug("Failed to restore %s.%s", module_name, attr)
def _unwrap_profile_home_to_base(home: Path) -> Path:
"""Return the base Hermes home when *home* is already a named profile dir."""
if home.parent.name == 'profiles':
@@ -625,6 +675,82 @@ def get_profile_runtime_env(home: Path) -> dict[str, str]:
return env
@contextmanager
def profile_env_for_background_worker(
session,
purpose: str = "background worker",
logger_override: Optional[logging.Logger] = None,
):
"""Temporarily route detached worker config reads through a profile.
Background WebUI workers run outside the request/streaming thread that
established the profile-scoped environment. Workers that read agent config,
runtime provider settings, or skill paths must temporarily apply the
session/request profile env or they can fall back to the server-default
profile. Pass either a session-like object with `.profile` or a profile name.
"""
log = logger_override or logger
raw_profile = session if isinstance(session, str) else getattr(session, "profile", "")
profile = str(raw_profile or "").strip()
if not profile or profile == "default":
yield
return
try:
# Lazy import avoids a module-load cycle: streaming imports this helper.
from api.streaming import _ENV_LOCK
profile_home_path = Path(get_hermes_home_for_profile(profile))
runtime_env = get_profile_runtime_env(profile_home_path)
except Exception:
log.debug(
"Failed to resolve profile env for %s profile %s; falling back to current env",
purpose,
profile,
exc_info=True,
)
yield
return
env_keys = set(runtime_env.keys()) | {"HERMES_HOME"}
# Stage-360 maintainer fix: narrow the _ENV_LOCK critical section to just
# the env mutation (and the env restoration). Pre-fix, this held _ENV_LOCK
# for the entire `yield` duration — i.e. the whole background worker's
# runtime (title generation, compression, update summary). That caused
# _ENV_LOCK to be held for many seconds, blocking ALL other sessions and
# surfacing as the QA `test_third_message_completes` timeout. The fix
# mirrors the narrow-lock pattern in _run_agent_streaming: acquire briefly
# to set env, run worker without holding the lock, reacquire to restore.
# See also QA `test_finally_restores_env_with_lock`.
skill_home_snapshot = None
old_env = {}
try:
with _ENV_LOCK:
old_env = {key: os.environ.get(key) for key in env_keys}
skill_home_snapshot = snapshot_skill_home_modules()
os.environ.update(runtime_env)
os.environ["HERMES_HOME"] = str(profile_home_path)
try:
patch_skill_home_modules(profile_home_path)
except Exception:
log.debug(
"Failed to patch skill modules for %s profile %s",
purpose,
profile,
exc_info=True,
)
yield
finally:
with _ENV_LOCK:
for key, old_value in old_env.items():
if old_value is None:
os.environ.pop(key, None)
else:
os.environ[key] = old_value
if skill_home_snapshot is not None:
restore_skill_home_modules(skill_home_snapshot)
def _set_hermes_home(home: Path):
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
os.environ['HERMES_HOME'] = str(home)
+92 -73
View File
@@ -5447,87 +5447,96 @@ def handle_post(handler, parsed) -> bool:
target = body.get("target") if isinstance(body, dict) else None
def _llm_update_summary(system_prompt: str, user_prompt: str) -> str:
from api.config import (
get_effective_default_model,
resolve_model_provider,
resolve_custom_provider_connection,
)
from api import profiles as profiles_api
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
active_profile = profiles_api.get_active_profile_name() or "default"
_main_model, _main_provider, _main_base_url = resolve_model_provider(get_effective_default_model())
_main_api_key = None
try:
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
from hermes_cli.runtime_provider import resolve_runtime_provider
_rt = resolve_runtime_provider_with_anthropic_env_lock(
resolve_runtime_provider,
requested=_main_provider,
with profiles_api.profile_env_for_background_worker(
active_profile,
"update summary",
logger_override=logger,
):
from api.config import (
get_effective_default_model,
resolve_model_provider,
resolve_custom_provider_connection,
)
_main_api_key = _rt.get("api_key")
if not _main_provider:
_main_provider = _rt.get("provider")
if not _main_base_url:
_main_base_url = _rt.get("base_url")
except Exception as _e:
logger.debug("update summary runtime provider resolution failed: %s", _e)
if isinstance(_main_provider, str) and _main_provider.startswith("custom:"):
_cp_key, _cp_base = resolve_custom_provider_connection(_main_provider)
if not _main_api_key and _cp_key:
_main_api_key = _cp_key
if not _main_base_url and _cp_base:
_main_base_url = _cp_base
main_runtime = {
"provider": _main_provider,
"model": _main_model,
"base_url": _main_base_url,
"api_key": _main_api_key,
}
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
try:
from agent.auxiliary_client import get_text_auxiliary_client
_main_model, _main_provider, _main_base_url = resolve_model_provider(get_effective_default_model())
_main_api_key = None
try:
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
from hermes_cli.runtime_provider import resolve_runtime_provider
# Update summaries are a short text-compression/summarization task.
# Reuse the documented auxiliary.compression slot instead of
# inventing a WebUI-only auxiliary task name that users cannot
# discover in the Hermes Agent setup/config UI.
aux_client, aux_model = get_text_auxiliary_client(
"compression",
main_runtime=main_runtime,
)
if aux_client is not None and aux_model:
response = aux_client.chat.completions.create(
model=aux_model,
messages=messages,
_rt = resolve_runtime_provider_with_anthropic_env_lock(
resolve_runtime_provider,
requested=_main_provider,
)
return str(response.choices[0].message.content or "").strip()
except Exception as _e:
logger.debug("update summary auxiliary model failed; falling back to main model: %s", _e)
_main_api_key = _rt.get("api_key")
if not _main_provider:
_main_provider = _rt.get("provider")
if not _main_base_url:
_main_base_url = _rt.get("base_url")
except Exception as _e:
logger.debug("update summary runtime provider resolution failed: %s", _e)
if isinstance(_main_provider, str) and _main_provider.startswith("custom:"):
_cp_key, _cp_base = resolve_custom_provider_connection(_main_provider)
if not _main_api_key and _cp_key:
_main_api_key = _cp_key
if not _main_base_url and _cp_base:
_main_base_url = _cp_base
from run_agent import AIAgent
main_runtime = {
"provider": _main_provider,
"model": _main_model,
"base_url": _main_base_url,
"api_key": _main_api_key,
}
agent = AIAgent(
model=_main_model,
provider=_main_provider,
base_url=_main_base_url,
api_key=_main_api_key,
platform="webui",
quiet_mode=True,
enabled_toolsets=[],
session_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
)
result = agent.run_conversation(
user_message=user_prompt,
system_message=system_prompt,
conversation_history=[],
task_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
)
return str(result.get("final_response") or "").strip()
try:
from agent.auxiliary_client import get_text_auxiliary_client
# Update summaries are a short text-compression/summarization task.
# Reuse the documented auxiliary.compression slot instead of
# inventing a WebUI-only auxiliary task name that users cannot
# discover in the Hermes Agent setup/config UI.
aux_client, aux_model = get_text_auxiliary_client(
"compression",
main_runtime=main_runtime,
)
if aux_client is not None and aux_model:
response = aux_client.chat.completions.create(
model=aux_model,
messages=messages,
)
return str(response.choices[0].message.content or "").strip()
except Exception as _e:
logger.debug("update summary auxiliary model failed; falling back to main model: %s", _e)
from run_agent import AIAgent
agent = AIAgent(
model=_main_model,
provider=_main_provider,
base_url=_main_base_url,
api_key=_main_api_key,
platform="webui",
quiet_mode=True,
enabled_toolsets=[],
session_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
)
result = agent.run_conversation(
user_message=user_prompt,
system_message=system_prompt,
conversation_history=[],
task_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
)
return str(result.get("final_response") or "").strip()
return j(handler, summarize_update_payload(updates, llm_callback=_llm_update_summary, target=target))
@@ -8272,7 +8281,17 @@ def _manual_compression_status_payload(job):
def _run_manual_compression_job(sid, body):
memory_handler = _ManualCompressionMemoryHandler()
try:
_handle_session_compress(memory_handler, body)
try:
session = get_session(sid)
except KeyError:
session = None
if session is not None:
from api import profiles as profiles_api
with profiles_api.profile_env_for_background_worker(session, "manual compression", logger_override=logger):
_handle_session_compress(memory_handler, body)
else:
_handle_session_compress(memory_handler, body)
status = int(memory_handler.status or 500)
payload = memory_handler.payload()
with _MANUAL_COMPRESSION_JOBS_LOCK:
+138 -27
View File
@@ -40,7 +40,11 @@ from api.turn_journal import append_turn_journal_event_for_stream
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
# interleave their os.environ writes. This global lock serializes the env
# save/restore around the entire agent run.
# save/restore — held only briefly across the env-mutation critical section,
# NOT for the entire agent run. The agent runs outside the lock; the finally
# block re-acquires to atomically restore env vars. See narrow-lock pattern
# in _run_agent_streaming (line ~2719) and profile_env_for_background_worker
# (api/profiles.py:715).
_ENV_LOCK = threading.Lock()
@@ -582,11 +586,98 @@ def _build_agent_thread_env(profile_runtime_env: dict | None, workspace: str, se
'TERMINAL_CWD': str(workspace),
'HERMES_EXEC_ASK': '1',
'HERMES_SESSION_KEY': session_id,
'HERMES_SESSION_ID': session_id,
'HERMES_SESSION_PLATFORM': 'webui',
'HERMES_HOME': profile_home,
})
return env
def _format_process_notification(evt: dict) -> str:
"""Format a completed background process notification for agent input."""
if not isinstance(evt, dict):
return ''
if evt.get('type') != 'completion':
return ''
_sid = evt.get('session_id', '')
_cmd = evt.get('command', '')
_exit = evt.get('exit_code', '')
_out = evt.get('output') or ''
if len(_out) > 4000:
_out = _out[:4000] + '\n... (truncated)'
return (
f"[IMPORTANT: Background process {_sid} completed (exit code {_exit}).\n"
f"Command: {_cmd}\n"
f"Output:\n{_out}]"
)
def _mark_process_completion_consumed(process_registry, process_id: str) -> None:
"""Best-effort bridge to the agent registry's private completion marker."""
try:
with process_registry._lock:
process_registry._completion_consumed.add(process_id)
except Exception:
logger.debug("Failed to mark process completion consumed", exc_info=True)
def _drain_webui_process_notifications(session_id: str) -> list[str]:
"""Return completion notifications that belong to this WebUI session.
The agent registry completion queue is process-wide and events do not carry
the WebUI session key directly. Look up the live process session before
delivery so completions from other tabs remain queued for their owners.
"""
if not session_id:
return []
try:
from tools.process_registry import process_registry
except Exception:
return []
notifications: list[str] = []
skipped_events: list[dict] = []
completion_queue = getattr(process_registry, 'completion_queue', None)
if completion_queue is None:
return []
while True:
try:
evt = completion_queue.get_nowait()
except queue.Empty:
break
except Exception:
logger.debug("Failed to drain process completion queue", exc_info=True)
break
evt_sid = str(evt.get('session_id') or '') if isinstance(evt, dict) else ''
if not evt_sid:
skipped_events.append(evt)
continue
try:
if process_registry.is_completion_consumed(evt_sid):
continue
proc = process_registry.get(evt_sid)
except Exception:
proc = None
if getattr(proc, 'session_key', None) != session_id:
skipped_events.append(evt)
continue
notification = _format_process_notification(evt)
if notification:
notifications.append(notification)
_mark_process_completion_consumed(process_registry, evt_sid)
for evt in skipped_events:
try:
completion_queue.put(evt)
except Exception:
logger.debug("Failed to requeue process completion event", exc_info=True)
break
return notifications
def _attachment_name(att) -> str:
if isinstance(att, dict):
return str(att.get('name') or att.get('filename') or att.get('path') or '').strip()
@@ -1467,24 +1558,27 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text
if not still_auto:
_put_title_status(put_event, session_id, 'skipped', 'manual_title', current)
return
aux_title_configured = _aux_title_configured()
if agent and not aux_title_configured:
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
if not next_title and llm_status in ('llm_error', 'llm_invalid'):
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent, use_agent_model=True)
else:
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text)
if not next_title and agent and llm_status in ('llm_error_aux', 'llm_invalid_aux'):
from api import profiles as profiles_api
with profiles_api.profile_env_for_background_worker(s, "background title", logger_override=logger):
aux_title_configured = _aux_title_configured()
if agent and not aux_title_configured:
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
source = llm_status
if not next_title:
fallback_title = _fallback_title_from_exchange(user_text, assistant_text)
if fallback_title and not _is_generic_fallback_title(fallback_title):
logger.debug("Using local fallback for session title generation")
next_title = fallback_title
source = 'fallback'
elif fallback_title:
logger.debug("Skipping generic local fallback for session title generation: %r", fallback_title)
if not next_title and llm_status in ('llm_error', 'llm_invalid'):
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent, use_agent_model=True)
else:
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text)
if not next_title and agent and llm_status in ('llm_error_aux', 'llm_invalid_aux'):
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
source = llm_status
if not next_title:
fallback_title = _fallback_title_from_exchange(user_text, assistant_text)
if fallback_title and not _is_generic_fallback_title(fallback_title):
logger.debug("Using local fallback for session title generation")
next_title = fallback_title
source = 'fallback'
elif fallback_title:
logger.debug("Skipping generic local fallback for session title generation: %r", fallback_title)
fallback_reason = (
f'local_summary:{llm_status}'
if source == 'fallback' and llm_status
@@ -1547,15 +1641,18 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex
return
if not effective or effective in ('Untitled', 'New Chat'):
return
aux_title_configured = _aux_title_configured()
if agent and not aux_title_configured:
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
if not next_title and llm_status in ('llm_error', 'llm_invalid'):
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent, use_agent_model=True)
else:
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text)
if not next_title and agent and llm_status in ('llm_error_aux', 'llm_invalid_aux'):
from api import profiles as profiles_api
with profiles_api.profile_env_for_background_worker(s, "background title", logger_override=logger):
aux_title_configured = _aux_title_configured()
if agent and not aux_title_configured:
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
if not next_title and llm_status in ('llm_error', 'llm_invalid'):
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent, use_agent_model=True)
else:
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text)
if not next_title and agent and llm_status in ('llm_error_aux', 'llm_invalid_aux'):
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
if not next_title:
_put_title_status(put_event, session_id, 'refresh_skipped', llm_status or 'empty', effective, raw_preview)
return
@@ -2376,6 +2473,8 @@ def _run_agent_streaming(
old_cwd = None
old_exec_ask = None
old_session_key = None
old_session_id = None
old_session_platform = None
old_hermes_home = None
old_profile_env = {}
@@ -2626,11 +2725,15 @@ def _run_agent_streaming(
old_cwd = os.environ.get('TERMINAL_CWD')
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
old_session_key = os.environ.get('HERMES_SESSION_KEY')
old_session_id = os.environ.get('HERMES_SESSION_ID')
old_session_platform = os.environ.get('HERMES_SESSION_PLATFORM')
old_hermes_home = os.environ.get('HERMES_HOME')
os.environ.update(_profile_runtime_env)
os.environ['TERMINAL_CWD'] = str(s.workspace)
os.environ['HERMES_EXEC_ASK'] = '1'
os.environ['HERMES_SESSION_KEY'] = session_id
os.environ['HERMES_SESSION_ID'] = session_id
os.environ['HERMES_SESSION_PLATFORM'] = 'webui'
if _profile_home:
os.environ['HERMES_HOME'] = _profile_home
# Patch module-level caches to match the active profile.
@@ -3382,7 +3485,11 @@ def _run_agent_streaming(
)
_ckpt_thread.start()
user_message = _build_native_multimodal_message(workspace_ctx, msg_text, attachments, workspace, cfg=_cfg)
_process_notifications = _drain_webui_process_notifications(session_id)
_agent_msg_text = msg_text
if _process_notifications:
_agent_msg_text = "\n\n".join([*_process_notifications, msg_text]).strip()
user_message = _build_native_multimodal_message(workspace_ctx, _agent_msg_text, attachments, workspace, cfg=_cfg)
result = agent.run_conversation(
user_message=user_message,
system_message=workspace_system_msg,
@@ -4272,6 +4379,10 @@ def _run_agent_streaming(
else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask
if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None)
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
if old_session_id is None: os.environ.pop('HERMES_SESSION_ID', None)
else: os.environ['HERMES_SESSION_ID'] = old_session_id
if old_session_platform is None: os.environ.pop('HERMES_SESSION_PLATFORM', None)
else: os.environ['HERMES_SESSION_PLATFORM'] = old_session_platform
if old_hermes_home is None: os.environ.pop('HERMES_HOME', None)
else: os.environ['HERMES_HOME'] = old_hermes_home
+2 -5
View File
@@ -201,11 +201,8 @@ function syncWorkspacePanelUI(){
const clearBtn=$('btnClearPreview');
if(clearBtn){
clearBtn.disabled=!isOpen;
_setButtonTooltip(clearBtn, hasPreview?'Close preview':'Hide workspace panel');
// On desktop, only show the X button when a file preview is open.
// In browse mode the chevron (btnCollapseWorkspacePanel) already serves
// as the close control, so showing both produces a duplicate X.
if(!isCompact) clearBtn.style.display=hasPreview?'':'none';
_setButtonTooltip(clearBtn, hasPreview?'Close preview':'Close');
if(!isCompact) clearBtn.style.display='';
}
}
+1 -3
View File
@@ -1181,17 +1181,15 @@
<aside class="rightpanel">
<div class="resize-handle" id="rightpanelResize"></div>
<div class="panel-header">
<span id="workspacePanelHeading" class="workspace-panel-heading" title="Workspace">Workspace</span><span id="workspaceHiddenIndicator" class="workspace-hidden-indicator" data-i18n-title="workspace_hidden_files_visible_title" title="Hidden files are visible — click for options" hidden onclick="toggleWorkspacePrefsMenu(event)"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span data-i18n="workspace_hidden_files_visible">hidden visible</span></span>
<div class="workspace-panel-title-group"><span id="workspacePanelHeading" class="workspace-panel-heading" title="Workspace">Workspace</span><span id="workspaceHiddenIndicator" class="workspace-hidden-indicator" data-i18n-title="workspace_hidden_files_visible_title" title="Hidden files are visible — click for options" hidden onclick="toggleWorkspacePrefsMenu(event)"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span data-i18n="workspace_hidden_files_visible">hidden visible</span></span></div>
<span class="git-badge" id="gitBadge" style="display:none"></span>
<div class="panel-actions">
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnCollapseWorkspacePanel" data-tooltip="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnUpDir" data-tooltip="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnNewFile" data-tooltip="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnNewFolder" data-tooltip="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnRefreshPanel" data-tooltip="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnWorkspacePrefs" data-tooltip="Workspace options" data-i18n-title="workspace_options" aria-haspopup="true" aria-expanded="false" onclick="toggleWorkspacePrefsMenu(event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg><span class="workspace-prefs-dot" id="workspacePrefsDot" hidden></span></button>
<button class="panel-icon-btn close-preview has-tooltip has-tooltip--bottom" id="btnClearPreview" data-tooltip="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button class="panel-icon-btn mobile-close-btn" onclick="handleWorkspaceClose()" title="Close" aria-label="Close workspace panel">×</button>
</div>
</div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
+18
View File
@@ -481,6 +481,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
clearInflightState(activeSid);
_clearActivePaneInflightIfOwner();
}
function _isMarkerOnlyAssistantMessage(m){
if(!m||m.role!=='assistant') return false;
const text=String(typeof msgContent==='function'?msgContent(m):(m.content||''));
return typeof _isPreservedCompressionTaskListMarkerOnlyText==='function'
&& _isPreservedCompressionTaskListMarkerOnlyText(text);
}
function _replaceMarkerOnlyAssistantWithStreamError(messages){
if(!Array.isArray(messages)) return false;
const msg=[...messages].reverse().find(m=>m&&m.role==='assistant');
if(!_isMarkerOnlyAssistantMessage(msg)) return false;
msg.content='**Error:** No response received after context compression. Please retry.';
msg.provider_details='The only assistant text returned for this turn was the internal preserved-task-list compression marker, so the WebUI replaced it with an explicit error instead of rendering the marker as a model response.';
return true;
}
function _setActivePaneIdleIfOwner(){
if(_isActiveSession()||!S.session||!INFLIGHT[S.session.session_id]){
setBusy(false);
@@ -1358,6 +1372,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
localStorage.setItem('hermes-webui-session',S.session.session_id);
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
}
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
if(
window._compressionUi&&window._compressionUi.automatic&&
window._compressionUi.sessionId===activeSid&&
@@ -1429,6 +1444,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.busy=false;
// 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(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length);
syncTopbar();renderMessages({preserveScroll:true});
if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
@@ -1713,6 +1729,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
localStorage.setItem('hermes-webui-session',S.session.session_id);
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
}
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
const hasMessageToolMetadata=S.messages.some(m=>{
if(!m||m.role!=='assistant') return false;
// Recognize both the standard `tool_calls` (used by completed assistant
+8 -18
View File
@@ -1279,19 +1279,17 @@
panel's width so the header can collapse less-essential elements as the
user resizes the panel narrower. */
.rightpanel{width:300px;background:var(--sidebar);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;min-width:0;opacity:1;transform:translateX(0);transform-origin:right center;transition:width .24s cubic-bezier(.22,1,.36,1),opacity .18s ease,transform .24s cubic-bezier(.22,1,.36,1),border-color .24s ease;container-type:inline-size;container-name:rightpanel;}
/* Collapse priority as the panel narrows: git-badge first, then "Workspace"
label, never the icon buttons. flex-shrink ratios give graceful ellipsis;
@container queries below cut to display:none at hard breakpoints. */
.panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;gap:6px;overflow:visible;}
.panel-header > span:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;flex-shrink:2;}
.panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:6px;overflow:visible;}
.workspace-panel-title-group{grid-column:1;grid-row:1;display:flex;align-items:center;min-width:0;}
.workspace-panel-title-group > span:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;}
.workspace-panel-heading{cursor:default;border-radius:6px;padding:2px 4px;margin:-2px -4px;}
.workspace-panel-heading.workspace-panel-heading--enabled{cursor:pointer;}
.workspace-panel-heading.workspace-panel-heading--enabled:hover,
.workspace-panel-heading.workspace-panel-heading--enabled:focus-visible{color:var(--text);background:var(--hover-bg);outline:none;}
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;flex-shrink:3;overflow:hidden;min-width:0;}
.git-badge{grid-column:1 / -1;grid-row:2;justify-self:start;font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;overflow:hidden;min-width:0;max-width:100%;}
.topbar-source-badge{display:inline-flex;align-items:center;margin-left:6px;padding:2px 7px;border-radius:999px;background:var(--accent-bg);color:var(--accent-text);font-size:10px;font-weight:700;letter-spacing:.02em;vertical-align:middle;}
.git-badge.dirty{color:var(--accent-text);background:var(--accent-bg);}
.panel-actions{display:flex;gap:4px;flex-shrink:0;margin-left:auto;}
.panel-actions{grid-column:2;grid-row:1;display:flex;gap:4px;flex-shrink:0;margin-left:auto;}
/* Crisp display:none at narrow widths so the header doesn't show a sliver
of an ellipsised label or git badge icons survive longest. */
@@ -1299,9 +1297,8 @@
.git-badge{display:none !important;}
}
@container rightpanel (max-width: 160px){
.panel-header > span:first-child{display:none;}
.workspace-panel-title-group{display:none;}
}
.mobile-close-btn{display:none;}
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
.panel-icon-btn:hover{background:var(--hover-bg);color:var(--text);}
.panel-icon-btn:disabled{opacity:.35;cursor:not-allowed;}
@@ -1310,7 +1307,6 @@
/* file-item-actions removed: delete button is now a flex child */
.file-action-btn{width:20px;height:20px;background:rgba(0,0,0,.4);border:none;border-radius:4px;color:var(--muted);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;}
.file-action-btn:hover{color:var(--accent);}
.close-preview{cursor:pointer;opacity:.6;}.close-preview:hover{opacity:1;}
/* Breadcrumb navigation */
.breadcrumb-bar{display:flex;align-items:center;gap:2px;padding:6px 12px;font-size:12px;border-bottom:1px solid var(--border);flex-shrink:0;overflow:hidden;white-space:nowrap;}
/* ── Workspace prefs kebab menu (#1793) ─────────────────────────── */
@@ -1437,9 +1433,6 @@
@media(max-width:900px){
.rightpanel{display:none}
.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}
.mobile-close-btn{display:flex;}
.close-preview{display:none;}
#btnCollapseWorkspacePanel{display:none;}
}
@container composer-footer (max-width: 700px){
@@ -1539,11 +1532,8 @@
box-shadow:none!important;}
.rightpanel.mobile-open{right:0!important;box-shadow:-4px 0 24px rgba(0,0,0,.4)!important;}
.rightpanel .resize-handle{display:none;}
.rightpanel .panel-header{display:grid;grid-template-columns:minmax(0,1fr) auto;row-gap:8px;}
.rightpanel .panel-header > span:first-child{grid-column:1;min-width:0;}
.rightpanel .git-badge{grid-column:2;justify-self:end;}
.rightpanel .panel-actions{grid-column:1 / -1;width:100%;align-items:center;gap:8px;justify-content:flex-start;margin-left:0;}
.rightpanel .mobile-close-btn{margin-left:auto;min-width:36px;min-height:36px;}
.rightpanel .panel-header{row-gap:8px;}
.rightpanel .panel-actions{align-items:center;gap:8px;}
/* Topbar adjustments */
.topbar{padding:8px 12px;gap:8px;}
.topbar-title{font-size:14px;}
+17
View File
@@ -4792,11 +4792,25 @@ function _isContextCompactionMessage(m){
const text=msgContent(m)||String(m.content||'');
return /^\s*\[context compaction/i.test(text) || /^\s*context compaction/i.test(text);
}
function _isPreservedCompressionTaskListMarkerText(text){
return /^\s*\[your active task list was preserved across context compression\]/i.test(String(text||''));
}
function _isPreservedCompressionTaskListMarkerOnlyText(text){
return _isPreservedCompressionTaskListMarkerText(text)
&& !String(text||'')
.replace(/^\s*\[your active task list was preserved across context compression\]\s*/i,'')
.trim();
}
function _isPreservedCompressionTaskListMessage(m){
if(!m||m.role!=='user') return false;
const text=msgContent(m)||String(m.content||'');
return /^\s*\[your active task list was preserved across context compression\]/i.test(text);
}
function _isMarkerOnlyAssistantCompressionMessage(m){
if(!m||m.role!=='assistant') return false;
const text=msgContent(m)||String(m.content||'');
return _isPreservedCompressionTaskListMarkerOnlyText(text);
}
function _preservedCompressionTaskListPreview(text){
const body=String(text||'')
.replace(/^\s*\[your active task list was preserved across context compression\]\s*/i,'')
@@ -5382,6 +5396,9 @@ function renderMessages(options){
}
}
const isUser=m.role==='user';
if(!isUser&&_isMarkerOnlyAssistantCompressionMessage(m)){
content='**Error:** No response received after context compression. Please retry.';
}
const displayContent=isUser?_stripWorkspaceDisplayPrefix(content):content;
const isLastAssistant=!isUser&&vi===renderVisWithIdx.length-1;
const nextRendered=renderVisWithIdx[vi+1];
@@ -0,0 +1,39 @@
from pathlib import Path
def _read(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
def test_preserved_task_list_marker_only_helper_is_strict():
src = _read("static/ui.js")
assert "function _isPreservedCompressionTaskListMarkerOnlyText" in src
start = src.find("function _isPreservedCompressionTaskListMarkerOnlyText")
end = src.find("function _isPreservedCompressionTaskListMessage", start)
helper = src[start:end]
assert "_isPreservedCompressionTaskListMarkerText(text)" in helper
assert ".replace(/^\\s*\\[your active task list was preserved across context compression\\]" in helper
assert ".trim()" in helper
def test_marker_only_assistant_message_renders_as_error_not_model_text():
src = _read("static/ui.js")
assert "function _isMarkerOnlyAssistantCompressionMessage" in src
assert "m.role!=='assistant'" in src
assert "_isPreservedCompressionTaskListMarkerOnlyText(text)" in src
assert "if(!isUser&&_isMarkerOnlyAssistantCompressionMessage(m))" in src
assert "content='**Error:** No response received after context compression. Please retry.'" in src
def test_done_and_restore_replace_marker_only_assistant_with_error_toast():
src = _read("static/messages.js")
assert "function _replaceMarkerOnlyAssistantWithStreamError(messages)" in src
assert "_isMarkerOnlyAssistantMessage(msg)" in src
assert "msg.content='**Error:** No response received after context compression. Please retry.'" in src
assert "internal preserved-task-list compression marker" in src
assert "_markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages)" in src
assert "showToast('No response received after context compression. Please retry.',5000,'error')" in src
+22 -77
View File
@@ -1,12 +1,12 @@
"""
Tests for issue #781 — duplicate X close button in workspace preview header
on window resize below 900px breakpoint.
Tests for issue #781 — duplicate X close button in workspace preview header.
The fix: a single btnClearPreview (.close-preview) is the only close button,
visible on all devices. The mobile-close-btn element was removed entirely.
Verifies that:
- .close-preview is hidden (display:none) inside the @media (max-width:900px) block
- .mobile-close-btn is shown (display:flex) inside the same @media block
Both rules must appear inside the same @media(max-width:900px) block so that
at mobile widths only the mobile-close-btn is visible.
- .close-preview is NOT hidden by any media query (visible everywhere)
- .mobile-close-btn has no CSS rules remaining (element removed from HTML)
"""
import re
@@ -44,88 +44,33 @@ def _extract_media_block(css, media_query_pattern):
raise AssertionError("Unmatched brace in CSS after @media block")
def _strip_media_blocks(css):
"""Remove all @media {...} blocks from CSS, returning base rules only."""
result = []
i = 0
while i < len(css):
# Look for @media keyword
m = re.search(r"@media\b", css[i:])
if not m:
result.append(css[i:])
break
# Append everything before this @media
result.append(css[i : i + m.start()])
# Find the opening brace of this @media block
brace_start = css.index("{", i + m.start())
depth = 0
j = brace_start
while j < len(css):
if css[j] == "{":
depth += 1
elif css[j] == "}":
depth -= 1
if depth == 0:
i = j + 1
break
j += 1
else:
break
return "".join(result)
_MEDIA_900_PATTERN = r"@media\s*\(\s*max-width\s*:\s*900px\s*\)"
def test_mobile_close_btn_displayed_in_900px_block():
"""mobile-close-btn must be display:flex inside the 900px media query."""
def test_close_preview_not_hidden_in_900px_block():
"""The single close button (.close-preview) must NOT be hidden in any media query."""
css = _load_css()
block = _extract_media_block(css, _MEDIA_900_PATTERN)
assert ".mobile-close-btn" in block, (
".mobile-close-btn rule is missing from @media(max-width:900px) block"
)
rule_match = re.search(r"\.mobile-close-btn\s*\{([^}]*)\}", block)
assert rule_match, ".mobile-close-btn rule body not found in 900px block"
assert "display:flex" in rule_match.group(1).replace(" ", ""), (
".mobile-close-btn should have display:flex in the 900px media query"
assert ".close-preview" not in block, (
".close-preview must not appear in @media(max-width:900px) block"
"the single X button should be visible on all devices"
)
def test_close_preview_hidden_in_900px_block():
""".close-preview must be display:none inside the 900px media query (fix for #781)."""
def test_mobile_close_btn_not_in_css():
"""mobile-close-btn CSS rules should have been removed entirely."""
css = _load_css()
block = _extract_media_block(css, _MEDIA_900_PATTERN)
assert ".close-preview" in block, (
".close-preview rule is missing from @media(max-width:900px) block — "
"the duplicate-button fix (#781) may have been reverted"
)
rule_match = re.search(r"\.close-preview\s*\{([^}]*)\}", block)
assert rule_match, ".close-preview rule body not found in 900px block"
assert "display:none" in rule_match.group(1).replace(" ", ""), (
".close-preview should have display:none in the 900px media query to hide "
"the duplicate desktop X button at mobile widths"
assert ".mobile-close-btn" not in css, (
".mobile-close-btn CSS rule still present — the element was removed "
"from HTML so its styles should be cleaned up too"
)
def test_both_rules_in_same_media_block():
"""Both .close-preview and .mobile-close-btn must appear in the same 900px block."""
def test_close_preview_visible_in_base_css():
"""Outside media queries, .close-preview must NOT be display:none."""
css = _load_css()
block = _extract_media_block(css, _MEDIA_900_PATTERN)
assert ".mobile-close-btn" in block, (
".mobile-close-btn missing from @media(max-width:900px) block"
)
assert ".close-preview" in block, (
".close-preview missing from @media(max-width:900px) block"
)
def test_close_preview_visible_outside_media_query():
"""Outside the media query, .close-preview must NOT be display:none
(it should remain visible on desktop)."""
css = _load_css()
base_css = _strip_media_blocks(css)
close_rules = re.findall(r"\.close-preview\s*\{([^}]*)\}", base_css)
for rule_body in close_rules:
assert "display:none" not in rule_body.replace(" ", ""), (
".close-preview must not be hidden in base (desktop) CSS"
# Simple check: find all .close-preview rules and ensure none set display:none
for m in re.finditer(r"\.close-preview\s*\{([^}]*)\}", css):
assert "display:none" not in m.group(1).replace(" ", ""), (
".close-preview must not be hidden by any CSS rule"
)
+2 -10
View File
@@ -205,16 +205,8 @@ def test_rightpanel_mobile_slide_over_css():
assert re.search(r'\.rightpanel\.mobile-open\{[^}]*box-shadow:\s*-4px 0 24px rgba\(0,\s*0,\s*0,\s*\.?4\)',
rightpanel_block, re.DOTALL), \
"open mobile rightpanel should keep the edge shadow"
assert re.search(r'\.rightpanel\s+\.panel-header\{[^}]*display:\s*grid[^}]*grid-template-columns:\s*minmax\(0,1fr\)\s+auto', rightpanel_block), \
"mobile workspace header should use a compact two-row grid"
assert re.search(r'\.rightpanel\s+\.panel-header\s*>\s*span:first-child\{[^}]*grid-column:\s*1', rightpanel_block), \
"mobile workspace heading should stay on the first row"
assert re.search(r'\.rightpanel\s+\.git-badge\{[^}]*grid-column:\s*2[^}]*justify-self:\s*end', rightpanel_block), \
"mobile git badge should share the first row with the heading"
assert re.search(r'\.rightpanel\s+\.panel-actions\{[^}]*grid-column:\s*1 / -1[^}]*width:\s*100%', rightpanel_block), \
"mobile workspace actions should span the full second row"
assert re.search(r'\.rightpanel\s+\.mobile-close-btn\{[^}]*margin-left:\s*auto', rightpanel_block), \
"mobile workspace close button should align to the far right"
assert re.search(r'\.rightpanel\s+\.panel-header\{[^}]*row-gap:\s*8px', rightpanel_block), \
"mobile workspace header should keep comfortable row spacing"
def test_workspace_panel_inline_width_is_desktop_only():
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
def test_webui_drains_only_matching_background_completion_events():
src = Path("api/streaming.py").read_text(encoding="utf-8")
assert "def _drain_webui_process_notifications(session_id: str)" in src
assert "from tools.process_registry import process_registry" in src
assert "proc = process_registry.get(evt_sid)" in src
assert "getattr(proc, 'session_key', None) != session_id" in src
assert "skipped_events.append(evt)" in src
assert "completion_queue.put(evt)" in src
def test_webui_injects_process_notifications_without_persisting_them_as_user_text():
src = Path("api/streaming.py").read_text(encoding="utf-8")
assert "_process_notifications = _drain_webui_process_notifications(session_id)" in src
assert "[*_process_notifications, msg_text]" in src
assert "_build_native_multimodal_message(workspace_ctx, _agent_msg_text" in src
assert "persist_user_message=msg_text" in src
def test_webui_sets_gateway_session_platform_for_background_watchers():
src = Path("api/streaming.py").read_text(encoding="utf-8")
assert "'HERMES_SESSION_PLATFORM': 'webui'" in src
assert "os.environ['HERMES_SESSION_PLATFORM'] = 'webui'" in src
assert "old_session_platform = os.environ.get('HERMES_SESSION_PLATFORM')" in src
assert "os.environ.pop('HERMES_SESSION_PLATFORM', None)" in src
+6 -1
View File
@@ -27,8 +27,13 @@ def _reload_profiles_module(base_home: Path):
profiles = importlib.import_module("api.profiles")
# Restore original modules so the cache stays consistent for the rest of the suite.
# Restore original modules and package attributes so the cache stays
# consistent for the rest of the suite.
sys.modules.update(_saved)
api_pkg = sys.modules.get("api")
if api_pkg is not None:
for name, module in _saved.items():
setattr(api_pkg, name.rsplit(".", 1)[-1], module)
return profiles
+4
View File
@@ -78,6 +78,8 @@ def test_streaming_thread_env_allows_profile_terminal_cwd_override():
"TERMINAL_CWD": "/profile/config/cwd",
"HERMES_EXEC_ASK": "0",
"HERMES_SESSION_KEY": "old-session",
"HERMES_SESSION_ID": "old-session",
"HERMES_SESSION_PLATFORM": "cli",
"HERMES_HOME": "/old/profile/home",
"TERMINAL_ENV": "ssh",
},
@@ -89,5 +91,7 @@ def test_streaming_thread_env_allows_profile_terminal_cwd_override():
assert env["TERMINAL_CWD"] == "/active/workspace"
assert env["HERMES_EXEC_ASK"] == "1"
assert env["HERMES_SESSION_KEY"] == "active-session"
assert env["HERMES_SESSION_ID"] == "active-session"
assert env["HERMES_SESSION_PLATFORM"] == "webui"
assert env["HERMES_HOME"] == "/active/profile/home"
assert env["TERMINAL_ENV"] == "ssh"
+24 -4
View File
@@ -712,11 +712,31 @@ class TestSSRFCheck:
class TestENVLock:
def test_env_lock_importable_from_streaming(self):
"""_ENV_LOCK must be importable from api.streaming."""
"""_ENV_LOCK must be an importable threading-style lock.
Stage-360 maintainer fix: _ENV_LOCK MUST be non-reentrant
(threading.Lock, not RLock) see QA test_env_lock_is_non_reentrant.
RLock would mask the deadlock pattern that the lock is designed to
catch. Background workers that need profile env should use the
narrow-lock pattern in profile_env_for_background_worker() rather
than relying on reentrance.
"""
from api.streaming import _ENV_LOCK
import threading
assert isinstance(_ENV_LOCK, type(threading.Lock())), \
"_ENV_LOCK must be a threading.Lock"
assert hasattr(_ENV_LOCK, "acquire"), "_ENV_LOCK must expose acquire()"
assert hasattr(_ENV_LOCK, "release"), "_ENV_LOCK must expose release()"
assert hasattr(_ENV_LOCK, "__enter__"), "_ENV_LOCK must support context manager use"
assert hasattr(_ENV_LOCK, "__exit__"), "_ENV_LOCK must support context manager use"
# Verify non-reentrance: a same-thread second acquire(blocking=False)
# while the lock is held must fail. This invariant matters because
# it catches a class of deadlock bugs early.
with _ENV_LOCK:
acquired_again = _ENV_LOCK.acquire(False)
assert not acquired_again, (
"_ENV_LOCK must be non-reentrant (threading.Lock, not RLock). "
"See QA test_env_lock_is_non_reentrant for the architectural reason."
)
def test_env_lock_importable_in_routes(self):
"""api.routes must be able to import _ENV_LOCK from api.streaming."""
+1 -70
View File
@@ -1,15 +1,11 @@
"""
Sprint 41 Tests: Title auto-generation fix + mobile close button CSS (PR #333).
Sprint 41 Tests: Title auto-generation fix (PR #333).
Covers:
- streaming.py: sessions titled 'New Chat' trigger auto-title generation
- streaming.py: sessions with empty/falsy title trigger auto-title generation
- streaming.py: sessions titled 'Untitled' (original guard) still trigger
- streaming.py: sessions with a user-set title do NOT trigger auto-title
- style.css: .mobile-close-btn is hidden by default (desktop rule present)
- style.css: .mobile-close-btn shown in <=900px media query
- style.css: #btnCollapseWorkspacePanel hidden in <=900px media query
- index.html: both .mobile-close-btn and #btnCollapseWorkspacePanel buttons exist
"""
import pathlib
import re
@@ -59,71 +55,6 @@ class TestTitleAutoGenerationCondition(unittest.TestCase):
"Expected at least 3 OR-joined sub-conditions (Untitled, New Chat, not s.title)")
# ── style.css: mobile close button visibility ─────────────────────────────
class TestMobileCloseButtonCSS(unittest.TestCase):
"""Verify CSS rules that control the duplicate close button on mobile."""
def test_mobile_close_btn_hidden_by_default(self):
"""Desktop default: .mobile-close-btn must be display:none outside any media query."""
# Find the rule before the first @media block that contains mobile-close-btn
# We look for the pattern in the desktop (non-media-query) section
self.assertIn(
".mobile-close-btn{display:none;}",
CSS.replace(" ", ""),
".mobile-close-btn should be hidden by default (desktop) — rule missing or wrong"
)
def test_mobile_close_btn_shown_in_900px_query(self):
"""Inside max-width:900px media query, .mobile-close-btn must be display:flex."""
# Extract the 900px media block
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
CSS)
self.assertIsNotNone(m, "@media(max-width:900px) block not found in style.css")
block = m.group(1).replace(" ", "")
self.assertIn(".mobile-close-btn{display:flex;}",
block,
".mobile-close-btn must be display:flex inside the 900px media query")
def test_desktop_collapse_btn_hidden_in_900px_query(self):
"""Inside max-width:900px media query, #btnCollapseWorkspacePanel must be display:none."""
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
CSS)
self.assertIsNotNone(m, "@media(max-width:900px) block not found in style.css")
block = m.group(1).replace(" ", "")
self.assertIn("#btnCollapseWorkspacePanel{display:none;}",
block,
"#btnCollapseWorkspacePanel must be display:none in 900px media query")
def test_900px_query_retains_existing_rules(self):
"""Ensure the PR didn't accidentally drop existing rules from the 900px block."""
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
CSS)
self.assertIsNotNone(m)
block = m.group(1)
self.assertIn("rightpanel", block, ".rightpanel rule missing from 900px block")
self.assertIn("mobile-files-btn", block, ".mobile-files-btn rule missing from 900px block")
# ── index.html: button presence ───────────────────────────────────────────
class TestWorkspacePanelButtons(unittest.TestCase):
"""Verify both panel buttons are present in the HTML so CSS rules have targets."""
def test_desktop_collapse_button_exists(self):
self.assertIn("btnCollapseWorkspacePanel", HTML,
"#btnCollapseWorkspacePanel button must exist in index.html")
def test_mobile_close_button_exists(self):
self.assertIn("mobile-close-btn", HTML,
".mobile-close-btn button must exist in index.html")
def test_mobile_close_button_has_aria_label(self):
"""Accessibility: mobile close button must have an aria-label."""
m = re.search(r'class="[^"]*mobile-close-btn[^"]*"[^>]*>', HTML)
self.assertIsNotNone(m, "Could not find mobile-close-btn element")
self.assertIn("aria-label", m.group(0),
"mobile-close-btn must have aria-label for accessibility")
class TestIssue495TitleStreaming(unittest.TestCase):
+73 -109
View File
@@ -1,15 +1,13 @@
"""
Sprint 44 Tests: Workspace panel close button fixes (PR #413).
Sprint 44 Tests: Workspace panel close button (PR #413).
Covers:
- index.html: mobile-close-btn now calls handleWorkspaceClose() instead of
closeWorkspacePanel(), so hitting X while a file is open returns you to the
file browser rather than collapsing the whole panel.
- boot.js: syncWorkspacePanelUI() hides #btnClearPreview (the X icon) on
desktop when no file preview is open, eliminating the duplicate X that
appeared alongside the chevron collapse button.
- boot.js: handleWorkspaceClose() logic clears preview when one is visible,
closes panel otherwise (existing function, confirmed wired to both buttons).
- btnClearPreview is the single close button for all devices. Clicking it calls
handleWorkspaceClose(), which clears a file preview if open, or closes the
entire workspace panel otherwise.
- The mobile-close-btn element was removed only one X button remains.
- Tooltip text updated to "Close" (when no preview) and "Close preview"
(when a file is being viewed).
"""
import pathlib
import re
@@ -20,113 +18,79 @@ HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
class TestMobileCloseButtonBehavior(unittest.TestCase):
"""mobile-close-btn must call handleWorkspaceClose(), not closeWorkspacePanel()."""
class TestSingleCloseButton(unittest.TestCase):
"""btnClearPreview is the only close button, visible on all devices."""
def test_mobile_close_btn_calls_handle_workspace_close(self):
"""mobile-close-btn onclick must be handleWorkspaceClose(), not closeWorkspacePanel()."""
m = re.search(r'class="[^"]*mobile-close-btn[^"]*"[^>]*>', HTML)
self.assertIsNotNone(m, "mobile-close-btn element not found in index.html")
btn_html = m.group(0)
self.assertIn(
'onclick="handleWorkspaceClose()"',
btn_html,
"mobile-close-btn must call handleWorkspaceClose() so that hitting X "
"while a file is open closes the file first, not the whole panel",
)
def test_mobile_close_btn_does_not_call_close_workspace_panel_directly(self):
"""mobile-close-btn must NOT call closeWorkspacePanel() directly."""
m = re.search(r'class="[^"]*mobile-close-btn[^"]*"[^>]*>', HTML)
self.assertIsNotNone(m, "mobile-close-btn element not found in index.html")
btn_html = m.group(0)
self.assertNotIn(
'onclick="closeWorkspacePanel()"',
btn_html,
"mobile-close-btn must not call closeWorkspacePanel() directly — "
"it would bypass the two-step close logic and collapse the panel even "
"when a file is being viewed",
)
def test_handle_workspace_close_defined_in_boot_js(self):
"""handleWorkspaceClose() must be defined in boot.js."""
self.assertIn(
"function handleWorkspaceClose()",
BOOT_JS,
"handleWorkspaceClose() is missing from boot.js",
)
def test_handle_workspace_close_clears_preview_first(self):
"""handleWorkspaceClose() must call clearPreview() when a preview is visible."""
# The function must check for visible preview and call clearPreview
self.assertIn(
"clearPreview()",
BOOT_JS,
"handleWorkspaceClose() must call clearPreview() when preview is visible",
)
def test_handle_workspace_close_falls_back_to_close_panel(self):
"""handleWorkspaceClose() must call closeWorkspacePanel() as fallback."""
# Find the function start and extract until the closing brace by scanning
start = BOOT_JS.find("function handleWorkspaceClose()")
self.assertNotEqual(start, -1, "handleWorkspaceClose() not found in boot.js")
# Extract a generous window after the function start
fn_window = BOOT_JS[start : start + 400]
self.assertIn(
"closeWorkspacePanel()",
fn_window,
"handleWorkspaceClose() must call closeWorkspacePanel() as its fallback path",
)
class TestDesktopNoDuplicateXButton(unittest.TestCase):
"""On desktop, only one X/close control should appear at a time."""
def test_sync_workspace_panel_ui_hides_clear_preview_on_desktop(self):
"""syncWorkspacePanelUI() must set display:none on btnClearPreview when no preview and desktop."""
self.assertIn(
"clearBtn.style.display",
BOOT_JS,
"syncWorkspacePanelUI() must control clearBtn.style.display to hide it "
"on desktop when no file preview is open",
)
def test_clear_preview_hidden_when_no_preview(self):
"""The display toggle for btnClearPreview must key off hasPreview."""
# Expect something like: clearBtn.style.display=hasPreview?'':'none'
# or clearBtn.style.display = hasPreview ? '' : 'none'
pattern = r"clearBtn\.style\.display\s*=\s*hasPreview"
self.assertRegex(
BOOT_JS,
pattern,
"btnClearPreview display must be conditioned on hasPreview in "
"syncWorkspacePanelUI() to avoid a duplicate X on desktop",
)
def test_clear_preview_toggle_only_applied_on_desktop(self):
"""The display toggle must be guarded by !isCompact so mobile is unaffected."""
# Expect: if(!isCompact) clearBtn.style.display=...
pattern = r"isCompact.*clearBtn\.style\.display|clearBtn\.style\.display.*isCompact"
self.assertRegex(
BOOT_JS,
pattern,
"btnClearPreview display toggle must be guarded by isCompact so the "
"mobile X button visibility is not accidentally affected",
)
def test_btnclearpreview_exists_in_html(self):
"""#btnClearPreview must still exist in the HTML (not removed)."""
def test_btn_clear_preview_exists_in_html(self):
"""#btnClearPreview must exist in index.html."""
self.assertIn(
'id="btnClearPreview"',
HTML,
"#btnClearPreview must remain in index.html",
"#btnClearPreview must be present in index.html",
)
def test_btncollapseWorkspacepanel_exists_in_html(self):
"""#btnCollapseWorkspacePanel (chevron) must still exist in the HTML."""
self.assertIn(
'id="btnCollapseWorkspacePanel"',
def test_mobile_close_btn_removed_from_html(self):
"""mobile-close-btn element should no longer exist in index.html."""
self.assertNotIn(
"mobile-close-btn",
HTML,
"#btnCollapseWorkspacePanel must remain in index.html",
"mobile-close-btn was removed — only btnClearPreview remains as close control",
)
def test_btn_clear_preview_wired_to_handle_workspace_close(self):
"""btnClearPreview onclick must be handleWorkspaceClose."""
self.assertRegex(
BOOT_JS,
r"btnClearPreview.*onclick\s*=\s*handleWorkspaceClose",
"btnClearPreview must call handleWorkspaceClose so that clicking X "
"either clears a preview or closes the panel",
)
class TestHandleWorkspaceCloseLogic(unittest.TestCase):
"""handleWorkspaceClose() clears preview first, falls back to close panel."""
def test_function_defined(self):
self.assertIn(
"function handleWorkspaceClose()",
BOOT_JS,
"handleWorkspaceClose() must exist in boot.js",
)
def test_clears_preview_when_open(self):
idx = BOOT_JS.find("function handleWorkspaceClose()")
self.assertGreater(idx, 0, "handleWorkspaceClose() not found")
body = BOOT_JS[idx:idx + 500]
self.assertIn(
"clearPreview()",
body,
"handleWorkspaceClose() must call clearPreview() when a preview is open",
)
def test_falls_back_to_close_panel(self):
idx = BOOT_JS.find("function handleWorkspaceClose()")
self.assertGreater(idx, 0, "handleWorkspaceClose() not found")
body = BOOT_JS[idx:idx + 500]
self.assertIn(
"closeWorkspacePanel()",
body,
"handleWorkspaceClose() must call closeWorkspacePanel() as fallback",
)
class TestTooltipText(unittest.TestCase):
"""The X button tooltip says 'Close' when no preview, 'Close preview' otherwise."""
def test_tooltip_uses_close(self):
"""syncWorkspacePanelUI() sets tooltip to 'Close' (not 'Hide workspace panel')."""
idx = BOOT_JS.find("function syncWorkspacePanelUI()")
self.assertGreater(idx, 0, "syncWorkspacePanelUI() not found")
body = BOOT_JS[idx:idx + 2000]
# The tooltip line should contain 'Close' and NOT 'Hide workspace panel'
self.assertIn(
"'Close'",
body,
"btnClearPreview tooltip must use 'Close' instead of 'Hide workspace panel'",
)
+66
View File
@@ -5,6 +5,7 @@ Sprint 46 Tests: manual session compression with optional focus topic.
import contextlib
import io
import json
import os
import sys
import threading
import time
@@ -406,6 +407,71 @@ def test_session_compress_async_reports_stream_state_guard(monkeypatch, cleanup_
assert get_session(sid).active_stream_id == "stream-concurrent"
def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path, cleanup_test_sessions):
import api.profiles as profiles
import api.routes as routes
class EnvAssertingAgent:
seen_env = None
def __init__(self, **kwargs):
skill_module = sys.modules.get("tools.skills_tool")
EnvAssertingAgent.seen_env = {
"HERMES_HOME": os.environ.get("HERMES_HOME"),
"HERMES_TEST_PROFILE_ENV": os.environ.get("HERMES_TEST_PROFILE_ENV"),
"SKILL_MODULE_HOME": getattr(skill_module, "HERMES_HOME", None),
"SKILL_MODULE_DIR": getattr(skill_module, "SKILLS_DIR", None),
}
self.context_compressor = _FakeCompressor()
created = cleanup_test_sessions
sid = _make_session()
created.append(sid)
session = get_session(sid)
session.profile = "work"
session.model_provider = "profile-provider"
session.save(touch_updated_at=False)
profile_home = tmp_path / "work-profile-home"
fake_skill_module = types.ModuleType("tools.skills_tool")
setattr(fake_skill_module, "HERMES_HOME", "default-home")
setattr(fake_skill_module, "SKILLS_DIR", "default-home/skills")
monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module)
monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda profile: profile_home)
monkeypatch.setattr(
profiles,
"get_profile_runtime_env",
lambda home: {"HERMES_TEST_PROFILE_ENV": "work-runtime"},
)
monkeypatch.setenv("HERMES_HOME", "default-home")
monkeypatch.delenv("HERMES_TEST_PROFILE_ENV", raising=False)
_install_fake_compression_runtime(monkeypatch, EnvAssertingAgent)
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
routes._MANUAL_COMPRESSION_JOBS[sid] = {
"session_id": sid,
"focus_topic": None,
"status": "running",
"started_at": time.time(),
"updated_at": time.time(),
}
routes._run_manual_compression_job(sid, {"session_id": sid})
assert EnvAssertingAgent.seen_env == {
"HERMES_HOME": str(profile_home),
"HERMES_TEST_PROFILE_ENV": "work-runtime",
"SKILL_MODULE_HOME": profile_home,
"SKILL_MODULE_DIR": profile_home / "skills",
}
assert str(getattr(fake_skill_module, "HERMES_HOME")) == "default-home"
assert str(getattr(fake_skill_module, "SKILLS_DIR")) == "default-home/skills"
assert os.environ.get("HERMES_HOME") == "default-home"
assert os.environ.get("HERMES_TEST_PROFILE_ENV") is None
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
assert routes._MANUAL_COMPRESSION_JOBS[sid]["status"] == "done"
def test_static_commands_js_registers_compress_alias(cleanup_test_sessions):
from pathlib import Path
+125
View File
@@ -6,6 +6,7 @@ Covers:
- auxagent fallback triggers on 'llm_invalid_aux' status
- _aux_title_timeout rejects zero, negative, and non-numeric values
"""
import os
import sys
import types
import unittest
@@ -378,6 +379,130 @@ class TestReasoningModelTitleGeneration(unittest.TestCase):
mock_session.save.assert_not_called()
class TestBackgroundTitleProfileRouting(unittest.TestCase):
def test_profile_env_context_logs_fail_open_resolution_errors(self):
"""Profile env setup failures should be diagnosable without breaking workers."""
import api.profiles as profiles
session = types.SimpleNamespace(profile='work')
captured = {}
with patch.object(
profiles,
'get_hermes_home_for_profile',
side_effect=RuntimeError('profile lookup failed'),
):
with patch.dict(os.environ, {'HERMES_HOME': 'default-home'}, clear=False):
with self.assertLogs('api.profiles', level='DEBUG') as logs:
with profiles.profile_env_for_background_worker(session, 'background title'):
captured['HERMES_HOME'] = os.environ.get('HERMES_HOME')
message_found = any(
'Failed to resolve profile env for background title profile work' in record.getMessage()
for record in logs.records
)
self.assertEqual(captured['HERMES_HOME'], 'default-home')
self.assertTrue(message_found)
self.assertTrue(any(record.exc_info for record in logs.records))
def test_skill_home_snapshot_removes_modules_imported_during_context(self):
"""Modules first imported inside a temporary profile context must not leak."""
import api.profiles as profiles
original_parent = sys.modules.get('tools')
original_skill_module = sys.modules.get('tools.skills_tool')
original_manager_module = sys.modules.get('tools.skill_manager_tool')
sys.modules.pop('tools.skills_tool', None)
sys.modules.pop('tools.skill_manager_tool', None)
tools_parent = types.ModuleType('tools')
sys.modules['tools'] = tools_parent
try:
snapshot = profiles.snapshot_skill_home_modules()
imported_during_context = types.ModuleType('tools.skills_tool')
setattr(imported_during_context, 'HERMES_HOME', 'profile-home')
setattr(imported_during_context, 'SKILLS_DIR', 'profile-home/skills')
sys.modules['tools.skills_tool'] = imported_during_context
setattr(tools_parent, 'skills_tool', imported_during_context)
profiles.restore_skill_home_modules(snapshot)
self.assertNotIn('tools.skills_tool', sys.modules)
self.assertFalse(hasattr(tools_parent, 'skills_tool'))
finally:
sys.modules.pop('tools.skills_tool', None)
sys.modules.pop('tools.skill_manager_tool', None)
if original_parent is None:
sys.modules.pop('tools', None)
else:
sys.modules['tools'] = original_parent
if original_skill_module is not None:
sys.modules['tools.skills_tool'] = original_skill_module
if original_manager_module is not None:
sys.modules['tools.skill_manager_tool'] = original_manager_module
@patch('api.streaming._aux_title_configured', return_value=True)
@patch('api.streaming.get_session')
def test_background_title_generation_uses_session_profile_home(
self, mock_get_session, mock_configured,
):
"""A background title worker for a non-default profile must resolve aux config from that profile."""
from api.streaming import _run_background_title_update
mock_session = MagicMock()
mock_session.title = 'Untitled'
mock_session.profile = 'work'
mock_session.llm_title_generated = False
mock_session.messages = [
{'role': 'user', 'content': 'This is a test message'},
{'role': 'assistant', 'content': 'Received.'},
]
mock_get_session.return_value = mock_session
captured = {}
original_skill_module = sys.modules.get('tools.skills_tool')
fake_skill_module = types.ModuleType('tools.skills_tool')
setattr(fake_skill_module, 'HERMES_HOME', 'default-home')
setattr(fake_skill_module, 'SKILLS_DIR', 'default-home/skills')
sys.modules['tools.skills_tool'] = fake_skill_module
def fake_aux_title(*args, **kwargs):
captured['hermes_home'] = os.environ.get('HERMES_HOME')
captured['skill_module_home'] = getattr(fake_skill_module, 'HERMES_HOME')
captured['skill_module_dir'] = getattr(fake_skill_module, 'SKILLS_DIR')
return ('Profile Routed Title', 'llm_aux', '')
events = []
try:
with patch('api.profiles.get_hermes_home_for_profile', return_value='profile-home'):
with patch('api.streaming._generate_llm_session_title_via_aux', side_effect=fake_aux_title):
with patch.dict(os.environ, {'HERMES_HOME': 'default-home'}, clear=False):
_run_background_title_update(
session_id='profile-title-session',
user_text='This is a test message',
assistant_text='Received.',
placeholder_title='Untitled',
put_event=lambda event_type, data: events.append((event_type, data)),
agent=None,
)
captured['restored_hermes_home'] = os.environ.get('HERMES_HOME')
finally:
if original_skill_module is None:
sys.modules.pop('tools.skills_tool', None)
else:
sys.modules['tools.skills_tool'] = original_skill_module
self.assertEqual(captured.get('hermes_home'), 'profile-home')
self.assertEqual(str(captured.get('skill_module_home')), 'profile-home')
self.assertEqual(str(captured.get('skill_module_dir')), 'profile-home/skills')
self.assertEqual(captured.get('restored_hermes_home'), 'default-home')
self.assertEqual(getattr(fake_skill_module, 'HERMES_HOME'), 'default-home')
self.assertEqual(getattr(fake_skill_module, 'SKILLS_DIR'), 'default-home/skills')
self.assertEqual(mock_session.title, 'Profile Routed Title')
class TestAuxTitleTimeoutEdgeCases(unittest.TestCase):
"""_aux_title_timeout must reject zero, negative, and non-numeric values."""
+146
View File
@@ -18,6 +18,9 @@ import threading
import time
import sys
import os
import io
import json
import types
REPO = pathlib.Path(__file__).parent.parent
@@ -431,6 +434,149 @@ class TestUpdateSummaryRouteModelSelection:
assert 'update summary auxiliary model failed; falling back to main model' in src
assert 'from run_agent import AIAgent' in src
def test_summary_route_auxiliary_model_uses_active_profile_env(self, monkeypatch, tmp_path):
import api.config as cfg
import api.profiles as profiles
import api.routes as routes
import api.updates as updates
class FakeHandler:
def __init__(self, payload):
raw = json.dumps(payload).encode('utf-8')
self.headers = {'Content-Length': str(len(raw))}
self.rfile = io.BytesIO(raw)
self.wfile = io.BytesIO()
self.status = None
def send_response(self, status):
self.status = status
def send_header(self, _key, _value):
pass
def end_headers(self):
pass
def response_payload(self):
return json.loads(self.wfile.getvalue().decode('utf-8'))
captured = {}
profile_home = tmp_path / 'profiles' / 'work'
fake_skill_module = types.ModuleType('tools.skills_tool')
setattr(fake_skill_module, 'HERMES_HOME', 'default-home')
setattr(fake_skill_module, 'SKILLS_DIR', 'default-home/skills')
monkeypatch.setitem(sys.modules, 'tools.skills_tool', fake_skill_module)
monkeypatch.setattr(profiles, 'get_hermes_home_for_profile', lambda profile: profile_home)
monkeypatch.setattr(
profiles,
'get_profile_runtime_env',
lambda home: {'HERMES_TEST_PROFILE_ENV': 'work-runtime'},
)
monkeypatch.setattr(cfg, 'get_effective_default_model', lambda: 'openai/test-main')
def fake_resolve_model_provider(model):
captured['model_resolution_env'] = {
'HERMES_HOME': os.environ.get('HERMES_HOME'),
'HERMES_TEST_PROFILE_ENV': os.environ.get('HERMES_TEST_PROFILE_ENV'),
}
return model, 'openai', 'https://example.test/v1'
monkeypatch.setattr(cfg, 'resolve_model_provider', fake_resolve_model_provider)
monkeypatch.setattr(cfg, 'resolve_custom_provider_connection', lambda provider: (None, None))
fake_runtime_provider = types.ModuleType('hermes_cli.runtime_provider')
fake_runtime_provider.resolve_runtime_provider = lambda requested=None: {
'api_key': 'fake-key',
'provider': requested or 'openai',
'base_url': 'https://example.test/v1',
}
fake_hermes_cli = types.ModuleType('hermes_cli')
fake_hermes_cli.__path__ = []
fake_hermes_cli.runtime_provider = fake_runtime_provider
monkeypatch.setitem(sys.modules, 'hermes_cli', fake_hermes_cli)
monkeypatch.setitem(sys.modules, 'hermes_cli.runtime_provider', fake_runtime_provider)
class FakeAuxClient:
class chat:
class completions:
@staticmethod
def create(model, messages):
captured['aux_create'] = {'model': model, 'messages': messages}
return types.SimpleNamespace(
choices=[
types.SimpleNamespace(
message=types.SimpleNamespace(
content='Notice: Profile-routed update summaries work.'
)
)
]
)
def fake_get_text_auxiliary_client(task, main_runtime=None):
captured['aux_env'] = {
'HERMES_HOME': os.environ.get('HERMES_HOME'),
'HERMES_TEST_PROFILE_ENV': os.environ.get('HERMES_TEST_PROFILE_ENV'),
'SKILL_MODULE_HOME': getattr(fake_skill_module, 'HERMES_HOME'),
'SKILL_MODULE_DIR': getattr(fake_skill_module, 'SKILLS_DIR'),
}
captured['aux_task'] = task
captured['main_runtime'] = dict(main_runtime or {})
return FakeAuxClient(), 'profile-compression-model'
fake_auxiliary_client = types.ModuleType('agent.auxiliary_client')
fake_auxiliary_client.get_text_auxiliary_client = fake_get_text_auxiliary_client
fake_agent = types.ModuleType('agent')
fake_agent.__path__ = []
fake_agent.auxiliary_client = fake_auxiliary_client
monkeypatch.setitem(sys.modules, 'agent', fake_agent)
monkeypatch.setitem(sys.modules, 'agent.auxiliary_client', fake_auxiliary_client)
with updates._cache_lock:
updates._summary_cache.clear()
monkeypatch.setenv('HERMES_HOME', 'default-home')
monkeypatch.setenv('HERMES_TEST_PROFILE_ENV', 'default-runtime')
body = {
'target': 'webui',
'updates': {
'webui': {
'behind': 1,
'current_sha': 'profile-env-before',
'latest_sha': f'profile-env-after-{time.time_ns()}',
'compare_url': 'https://example.test/compare',
},
},
}
handler = FakeHandler(body)
profiles.set_request_profile('work')
try:
routes.handle_post(handler, types.SimpleNamespace(path='/api/updates/summary'))
finally:
profiles.clear_request_profile()
assert handler.status == 200
payload = handler.response_payload()
assert payload['generated_by'] == 'llm'
assert captured['aux_task'] == 'compression'
assert captured['model_resolution_env'] == {
'HERMES_HOME': str(profile_home),
'HERMES_TEST_PROFILE_ENV': 'work-runtime',
}
assert captured['aux_env'] == {
'HERMES_HOME': str(profile_home),
'HERMES_TEST_PROFILE_ENV': 'work-runtime',
'SKILL_MODULE_HOME': profile_home,
'SKILL_MODULE_DIR': profile_home / 'skills',
}
assert captured['aux_create']['model'] == 'profile-compression-model'
assert getattr(fake_skill_module, 'HERMES_HOME') == 'default-home'
assert getattr(fake_skill_module, 'SKILLS_DIR') == 'default-home/skills'
assert os.environ.get('HERMES_HOME') == 'default-home'
assert os.environ.get('HERMES_TEST_PROFILE_ENV') == 'default-runtime'
class TestUiJsUpdateBanner:
"""#813 + #814 — UI must show persistent error, force button, and correct toast."""
+11 -16
View File
@@ -74,20 +74,20 @@ class TestWorkspacePanelCollapsePriority:
# in #1775 so its tooltip pseudo-elements can escape the header bar
# (otherwise the workspace-panel header tooltips like "New file" get
# clipped). The title-text ellipsis is preserved by the inner span
# `.panel-header > span:first-child` which has its own
# `.workspace-panel-title-group > span:first-child` which has its own
# overflow:hidden + text-overflow:ellipsis. So we check that EITHER
# the parent uses overflow:hidden (legacy) or that the inner span
# handles its own ellipsis (current).
if "overflow:hidden" not in rule:
inner_span_idx = STYLE_CSS.find(".panel-header > span:first-child{")
inner_span_idx = STYLE_CSS.find(".workspace-panel-title-group > span:first-child{")
assert inner_span_idx != -1, (
".panel-header lost overflow:hidden but no inner span "
"rule (.panel-header > span:first-child) handles the "
"rule (.workspace-panel-title-group > span:first-child) handles the "
"title-text ellipsis as a fallback."
)
inner_rule = STYLE_CSS[inner_span_idx: STYLE_CSS.find("}", inner_span_idx) + 1]
assert "overflow:hidden" in inner_rule and "text-overflow:ellipsis" in inner_rule, (
".panel-header > span:first-child must own the ellipsis "
".workspace-panel-title-group > span:first-child must own the ellipsis "
"behaviour now that the parent is overflow:visible."
)
@@ -106,26 +106,21 @@ class TestWorkspacePanelCollapsePriority:
)
def test_workspace_label_shrinks_with_ellipsis(self):
"""The "Workspace" label (`panel-header > span:first-child`) must
shrink with ellipsis truncation rather than overflow uncontrollably."""
"""The "Workspace" label must shrink with ellipsis truncation."""
# Find the rule
sel = ".panel-header > span:first-child"
sel = ".workspace-panel-title-group > span:first-child"
idx = STYLE_CSS.find(sel)
assert idx >= 0, f"Selector {sel!r} not found in style.css"
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
assert "text-overflow:ellipsis" in rule
assert "min-width:0" in rule
assert "flex-shrink:2" in rule # shrinks before icons (icons are 0)
def test_git_badge_shrinks_first(self):
"""`.git-badge` must shrink faster than the label so it disappears
first as the panel narrows."""
def test_git_badge_uses_second_row(self):
"""`.git-badge` should sit beneath the title/action row."""
idx = STYLE_CSS.find(".git-badge{")
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
assert "flex-shrink:3" in rule, (
".git-badge must have flex-shrink:3 so it shrinks before the "
"label (flex-shrink:2) and the icons (flex-shrink:0)."
)
assert "grid-column:1 / -1" in rule
assert "grid-row:2" in rule
def test_container_query_hides_git_badge_first(self):
"""At narrow widths the git badge gets `display:none` BEFORE the
@@ -145,7 +140,7 @@ class TestWorkspacePanelCollapsePriority:
assert "@container rightpanel (max-width: 160px)" in STYLE_CSS
idx = STYLE_CSS.find("@container rightpanel (max-width: 160px)")
block = STYLE_CSS[idx: idx + 200]
assert ".panel-header > span:first-child{display:none" in block
assert ".workspace-panel-title-group{display:none" in block
def test_breakpoints_in_correct_order(self):
"""Sanity: the git-badge breakpoint (220px) must be wider than the