diff --git a/CHANGELOG.md b/CHANGELOG.md index 139ec3ef..674486f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/profiles.py b/api/profiles.py index e226826d..3d1ecb2c 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -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) diff --git a/api/routes.py b/api/routes.py index e81571a9..d64ba1f7 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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: diff --git a/api/streaming.py b/api/streaming.py index 275888a5..391c245a 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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 diff --git a/static/boot.js b/static/boot.js index e967f292..af545ef1 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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=''; } } diff --git a/static/index.html b/static/index.html index c32974ce..592e6243 100644 --- a/static/index.html +++ b/static/index.html @@ -1181,17 +1181,15 @@