mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-27 20:20:20 +00:00
Merge pull request #2397 from nesquena/stage-368
v0.51.75 — Release AY (stage-368: 11-PR safe-lane batch + pytest-loop fix)
This commit is contained in:
@@ -2,6 +2,38 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.75] — 2026-05-16 — Release AY (stage-368 — 11-PR safe-lane batch — storage + i18n + run-journal parity + attachments + compression sidebar + restart-recovery + text-mode images + tables + settings i18n + German labels)
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
- Stage-368 maintainer fix — pytest no longer self-loops on the `_schedule_restart` daemon thread. Several existing tests in `tests/test_update_banner_fixes.py` call `api.updates._schedule_restart()`, which spawns a daemon thread that eventually calls `os.execv()`. Those tests monkeypatch `os.execv` for the test scope, but monkeypatch teardown can win the race against the daemon thread, restoring the real `os.execv` before the thread fires it — at which point the daemon re-execs the entire pytest process with the original argv, looking from the outside like pytest hangs at 99 % then restarts the suite from 0 % in an infinite loop. `tests/conftest.py` now installs a permanent no-op wrapper on `os.execv` at module-import time so late-firing daemon threads cannot re-exec pytest. New `tests/test_pytest_execv_guard.py` pins the guard against future regressions.
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2377** by @franksong2702 (refs #2283, refs #2363, refs #1925) — Run-journal replay timeline parity checks. After #2283 shipped the first run-journal replay slice and #2363 documented the cross-layer state consistency contract, this PR adds explicit parity assertions over the replayed timeline so divergences between the journal and the visible transcript (Thinking → tool calls → assistant text) surface as test failures instead of silent drift.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2391** by @Michaelyklam (fixes #2389) — Reduce browser storage pressure during service-worker updates and over long-running sessions. `static/sw.js` now calls `deleteOldShellCaches()` BEFORE `caches.open(CACHE_NAME)` in the install handler so the new ~2.2 MB shell cache no longer overlaps the old one during a version bump (especially painful on shared-origin quota accounting). A new `_clearSessionViewedCount()` helper plus extended `_clearHandoffStorageForSession()` prune `hermes-session-viewed-counts`, `hermes-session-completion-unread`, and `hermes-session-observed-streaming` on every single-session delete and batch-delete so per-session tracking maps no longer grow unbounded.
|
||||
|
||||
- **PR #2387** by @Michaelyklam (fixes #2386) — Guard `localStorage.setItem('hermes-webui-session', ...)` and workspace-panel runtime-state writes with `try { … } catch (_) {}` across `static/boot.js`, `static/sessions.js`, `static/commands.js`, and `static/messages.js`. These convenience writes were previously fatal UI operations on quota-exhausted browsers (especially Firefox public-domain setups where shared quota fills up after a service-worker shell rotation).
|
||||
|
||||
- **PR #2368** by @Michaelyklam — Hybridize background profile env routing so background title generation, manual compression, and update-summary workers honor a session's non-default profile. The pure thread-local refactor for #2321 was reverted because `hermes_cli.config.load_config()` still reads `HERMES_HOME` from process env. This PR keeps the thread-local layer for WebUI helpers and adds an `os.environ.update(runtime_env)` mirror under a narrow `_ENV_LOCK` for the worker body, with proper restore of prior values. New test asserts `OPENROUTER_API_KEY` is visible from the worker against a non-default profile.
|
||||
|
||||
- **PR #2382** by @Michaelyklam (fixes #2380) — Serve raw chat attachments from the per-session inbox in addition to the session workspace. Chat uploads were intentionally moved out of workspaces into a per-session attachment inbox in an earlier release; the transcript renderer still emits stable `api/file/raw?session_id=...&path=<filename>` URLs, but `_handle_file_raw` only checked `session.workspace` so inbox-backed uploads rendered as broken images. The URL surface is preserved and a session-attachment fallback is added with path-traversal guards intact.
|
||||
|
||||
- **PR #2385** by @franksong2702 — Keep fuller compression snapshots reachable in the sidebar. The default behavior hides `pre_compression_snapshot: true` rows so archived compression segments do not duplicate the active continuation. A real long Kanban session exposed a narrower failure: the fuller transcript was still present on disk but remained marked as `pre_compression_snapshot`, so the sidebar surfaced a shorter row and the fuller transcript became unreachable. The fix preserves discoverability without re-introducing duplication in normal cases.
|
||||
|
||||
- **PR #2371** by @franksong2702 — Clarify interrupted turn recovery after a WebUI restart. WebUI executes browser-originated agent turns inside the WebUI process; if that process restarts mid-turn, the worker dies with it. Run journal replay can only replay events that were already emitted, so the stale-pending repair path is now annotated and refined to make the post-restart state explicit (interrupted, recoverable, or terminal) instead of leaving the user with a half-rendered turn and no signal.
|
||||
|
||||
- **PR #2378** by @Michaelyklam — Strip historical images in text-only mode. Current-turn uploads already respect `agent.image_input_mode: text`, but saved conversation history still passed native `image_url` content parts back into later provider calls, breaking text-only providers on replayed turns. `_sanitize_messages_for_api()` gains a `cfg=` keyword argument so the API-history sanitizer can strip historical native image parts when the mode is text. Default `cfg=None` preserves prior behavior for callers that don't pass the new argument.
|
||||
|
||||
- **PR #2375** by @Michaelyklam — Keep Markdown tables block-level. Pipe tables were already converted to `<table>` markup, but the final paragraph pass did not treat generated tables as block-level output, occasionally wrapping them in `<p>` and breaking the surrounding layout. The fix isolates generated tables and adds `table` to the paragraph-wrap skip list so valid CommonMark tables render predictably.
|
||||
|
||||
- **PR #2372** by @mccxj — Settings → Conversation page action buttons now respect locale selection. Pre-fix, the JSON export, MD export, and Copy buttons had hardcoded English labels/titles. Adds `data-i18n` / `data-i18n-title` attributes plus the missing translation keys so non-English locales no longer see English labels stuck in the middle of a translated screen.
|
||||
|
||||
- **PR #2381** by @Michaelyklam (fixes #2379) — German relative session-time labels now interpolate the elapsed value instead of rendering the literal `{n}` placeholder in the sidebar/header. The German locale now uses function-valued translations for minutes, hours, and days, matching the other locale bundles.
|
||||
|
||||
## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n)
|
||||
|
||||
### Added
|
||||
|
||||
+125
-14
@@ -679,6 +679,20 @@ def _get_profile_home(profile) -> Path:
|
||||
return Path(os.environ.get('HERMES_HOME') or '~/.hermes').expanduser()
|
||||
|
||||
|
||||
def _interrupted_recovery_marker() -> dict:
|
||||
return {
|
||||
'role': 'assistant',
|
||||
'content': (
|
||||
'**Response interrupted.**\n\n'
|
||||
'The WebUI process restarted before this turn finished. '
|
||||
'The user message above was preserved, but no agent output was recovered.'
|
||||
),
|
||||
'timestamp': int(time.time()),
|
||||
'_error': True,
|
||||
'type': 'interrupted',
|
||||
}
|
||||
|
||||
|
||||
def _apply_core_sync_or_error_marker(
|
||||
session,
|
||||
core_path,
|
||||
@@ -745,12 +759,7 @@ def _apply_core_sync_or_error_marker(
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
session.pending_started_at = None
|
||||
session.messages.append({
|
||||
'role': 'assistant',
|
||||
'content': '**Previous turn did not complete.**',
|
||||
'timestamp': int(time.time()),
|
||||
'_error': True,
|
||||
})
|
||||
session.messages.append(_interrupted_recovery_marker())
|
||||
session.save()
|
||||
logger.info(
|
||||
"Session %s: recovered pending user turn (messages non-empty), added error marker",
|
||||
@@ -794,12 +803,7 @@ def _apply_core_sync_or_error_marker(
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
session.pending_started_at = None
|
||||
session.messages.append({
|
||||
'role': 'assistant',
|
||||
'content': '**Previous turn did not complete.**',
|
||||
'timestamp': int(time.time()),
|
||||
'_error': True,
|
||||
})
|
||||
session.messages.append(_interrupted_recovery_marker())
|
||||
session.save()
|
||||
logger.info("Session %s: no core transcript found, added error marker", sid)
|
||||
return True
|
||||
@@ -811,7 +815,7 @@ def _apply_core_sync_or_error_marker(
|
||||
# pending_user_message and STREAMS.pop(stream_id). Without this guard, any
|
||||
# fast turn (e.g. command approval) that exits the thread before the on-disk
|
||||
# pending clear has flushed gets misdiagnosed as a crashed turn, producing a
|
||||
# spurious "Previous turn did not complete." marker.
|
||||
# spurious "Response interrupted." marker.
|
||||
#
|
||||
# 30s covers the worst-case post-loop persistence window: LLM finishing a tool
|
||||
# batch + lock contention with the checkpoint thread + a multi-MB session.save.
|
||||
@@ -1012,7 +1016,110 @@ def _hide_from_default_sidebar(session: dict) -> bool:
|
||||
"""Return True for internal/background sessions hidden from the default list."""
|
||||
sid = str(session.get('session_id') or '')
|
||||
source = session.get('source_tag') or session.get('source')
|
||||
return bool(session.get('pre_compression_snapshot')) or source == 'cron' or sid.startswith('cron_')
|
||||
if source == 'cron' or sid.startswith('cron_'):
|
||||
return True
|
||||
if bool(session.get('pre_compression_snapshot')):
|
||||
return not bool(session.get('_show_pre_compression_snapshot'))
|
||||
return False
|
||||
|
||||
|
||||
def _sidebar_message_count(session: dict) -> int:
|
||||
for key in ('message_count', 'actual_message_count'):
|
||||
try:
|
||||
value = int(session.get(key) or 0)
|
||||
except (TypeError, ValueError):
|
||||
value = 0
|
||||
if value > 0:
|
||||
return value
|
||||
return 0
|
||||
|
||||
|
||||
def _sidebar_lineage_root_id(session: dict, sessions_by_id: dict[str, dict]) -> str:
|
||||
sid = str(session.get('session_id') or '')
|
||||
root = sid
|
||||
parent = session.get('parent_session_id')
|
||||
seen = {sid}
|
||||
while parent and parent not in seen and parent in sessions_by_id:
|
||||
root = str(parent)
|
||||
seen.add(root)
|
||||
parent = sessions_by_id.get(root, {}).get('parent_session_id')
|
||||
return root
|
||||
|
||||
|
||||
def _has_live_sidebar_state(session: dict) -> bool:
|
||||
return bool(
|
||||
session.get('active_stream_id')
|
||||
or session.get('has_pending_user_message')
|
||||
or session.get('pending_user_message')
|
||||
)
|
||||
|
||||
|
||||
def _prefer_fuller_snapshots_for_sidebar(sessions: list[dict]) -> list[dict]:
|
||||
"""Expose a hidden snapshot when it is the fuller transcript for a lineage.
|
||||
|
||||
Pre-compression snapshots are normally hidden so archived compression
|
||||
segments do not duplicate the current continuation in the sidebar. If a
|
||||
snapshot row has more messages than the visible continuation for the same
|
||||
lineage, hiding it makes the conversation look truncated. In that case,
|
||||
show the fuller snapshot and suppress the shorter inactive continuation.
|
||||
"""
|
||||
sessions_by_id = {
|
||||
str(session.get('session_id')): session
|
||||
for session in sessions
|
||||
if session.get('session_id')
|
||||
}
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for session in sessions:
|
||||
sid = str(session.get('session_id') or '')
|
||||
source = session.get('source_tag') or session.get('source')
|
||||
if source == 'cron' or sid.startswith('cron_'):
|
||||
continue
|
||||
root = _sidebar_lineage_root_id(session, sessions_by_id)
|
||||
groups.setdefault(root, []).append(session)
|
||||
|
||||
snapshot_ids_to_show: set[str] = set()
|
||||
continuation_ids_to_hide: set[str] = set()
|
||||
for group in groups.values():
|
||||
visible = [session for session in group if not session.get('pre_compression_snapshot')]
|
||||
snapshots = [session for session in group if session.get('pre_compression_snapshot')]
|
||||
if not visible or not snapshots:
|
||||
continue
|
||||
if any(_has_live_sidebar_state(session) for session in visible):
|
||||
continue
|
||||
|
||||
best_visible_count = max(_sidebar_message_count(session) for session in visible)
|
||||
best_snapshot = max(
|
||||
snapshots,
|
||||
key=lambda session: (_sidebar_message_count(session), _session_sort_timestamp(session)),
|
||||
)
|
||||
if _sidebar_message_count(best_snapshot) <= best_visible_count:
|
||||
continue
|
||||
|
||||
snapshot_ids_to_show.add(str(best_snapshot.get('session_id')))
|
||||
continuation_ids_to_hide.update(
|
||||
str(session.get('session_id'))
|
||||
for session in visible
|
||||
if session.get('session_id')
|
||||
)
|
||||
|
||||
if not snapshot_ids_to_show and not continuation_ids_to_hide:
|
||||
return sessions
|
||||
|
||||
out = []
|
||||
for session in sessions:
|
||||
sid = str(session.get('session_id') or '')
|
||||
if sid in continuation_ids_to_hide:
|
||||
continue
|
||||
if sid in snapshot_ids_to_show:
|
||||
session = dict(session)
|
||||
session['_show_pre_compression_snapshot'] = True
|
||||
out.append(session)
|
||||
return out
|
||||
|
||||
|
||||
def _strip_sidebar_internal_flags(sessions: list[dict]) -> None:
|
||||
for session in sessions:
|
||||
session.pop('_show_pre_compression_snapshot', None)
|
||||
|
||||
|
||||
def _active_state_db_path() -> Path:
|
||||
@@ -1131,7 +1238,9 @@ def all_sessions(diag=None):
|
||||
and not s.get('has_pending_user_message')
|
||||
and not s.get('worktree_path')
|
||||
)]
|
||||
result = _prefer_fuller_snapshots_for_sidebar(result)
|
||||
result = [s for s in result if not _hide_from_default_sidebar(s)]
|
||||
_strip_sidebar_internal_flags(result)
|
||||
# Backfill: sessions created before Sprint 22 have no profile tag.
|
||||
# Attribute them to 'default' so the client profile filter works correctly.
|
||||
for s in result:
|
||||
@@ -1167,7 +1276,9 @@ def all_sessions(diag=None):
|
||||
and not s.pending_user_message
|
||||
and not getattr(s, 'worktree_path', None)
|
||||
)]
|
||||
result = _prefer_fuller_snapshots_for_sidebar(result)
|
||||
result = [s for s in result if not _hide_from_default_sidebar(s)]
|
||||
_strip_sidebar_internal_flags(result)
|
||||
for s in result:
|
||||
if not s.get('profile'):
|
||||
s['profile'] = 'default'
|
||||
|
||||
+26
-14
@@ -697,7 +697,8 @@ def profile_env_for_background_worker(
|
||||
return
|
||||
|
||||
try:
|
||||
# Lazy import avoids a module-load cycle: streaming imports this helper.
|
||||
# Lazy imports avoid a module-load cycle: streaming imports this helper.
|
||||
from api.config import _clear_thread_env, _set_thread_env, _thread_ctx
|
||||
from api.streaming import _ENV_LOCK
|
||||
|
||||
profile_home_path = Path(get_hermes_home_for_profile(profile))
|
||||
@@ -712,21 +713,24 @@ def profile_env_for_background_worker(
|
||||
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`.
|
||||
thread_env = dict(runtime_env)
|
||||
thread_env["HERMES_HOME"] = str(profile_home_path)
|
||||
# Hybrid profile routing: keep the broad runtime env in WebUI's thread-local
|
||||
# channel for WebUI helpers, and also mirror it into process env for the
|
||||
# worker body because several production Hermes readers still call
|
||||
# os.getenv() directly for provider credentials. Keep the _ENV_LOCK scope
|
||||
# narrow: serialize only setup/restore, not the whole worker body.
|
||||
skill_home_snapshot = None
|
||||
old_env = {}
|
||||
old_runtime_env: dict[str, Optional[str]] = {}
|
||||
old_hermes_home = None
|
||||
had_hermes_home = False
|
||||
previous_thread_env = getattr(_thread_ctx, "env", {}).copy()
|
||||
try:
|
||||
_set_thread_env(**thread_env)
|
||||
with _ENV_LOCK:
|
||||
old_env = {key: os.environ.get(key) for key in env_keys}
|
||||
old_runtime_env = {key: os.environ.get(key) for key in runtime_env}
|
||||
had_hermes_home = "HERMES_HOME" in os.environ
|
||||
old_hermes_home = os.environ.get("HERMES_HOME")
|
||||
skill_home_snapshot = snapshot_skill_home_modules()
|
||||
os.environ.update(runtime_env)
|
||||
os.environ["HERMES_HOME"] = str(profile_home_path)
|
||||
@@ -741,12 +745,20 @@ def profile_env_for_background_worker(
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
if previous_thread_env:
|
||||
_set_thread_env(**previous_thread_env)
|
||||
else:
|
||||
_clear_thread_env()
|
||||
with _ENV_LOCK:
|
||||
for key, old_value in old_env.items():
|
||||
for key, old_value in old_runtime_env.items():
|
||||
if old_value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = old_value
|
||||
if had_hermes_home:
|
||||
os.environ["HERMES_HOME"] = old_hermes_home or ""
|
||||
else:
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
if skill_home_snapshot is not None:
|
||||
restore_skill_home_modules(skill_home_snapshot)
|
||||
|
||||
|
||||
+26
-2
@@ -6360,10 +6360,34 @@ def _handle_media(handler, parsed):
|
||||
or html_inline_ok
|
||||
)
|
||||
) else "attachment"
|
||||
# _serve_file_bytes sends Content-Security-Policy when csp is set.
|
||||
csp = "sandbox allow-scripts" if html_inline_ok else None
|
||||
return _serve_file_bytes(handler, target, mime, disposition, "private, max-age=3600", csp=csp)
|
||||
|
||||
|
||||
def _file_raw_target(session, sid: str, rel: str) -> Path | None:
|
||||
"""Resolve /api/file/raw paths from the workspace or this session's uploads."""
|
||||
try:
|
||||
target = safe_resolve(Path(session.workspace), rel)
|
||||
except ValueError:
|
||||
target = None
|
||||
if target and target.exists() and target.is_file():
|
||||
return target
|
||||
|
||||
# Chat uploads now live in a per-session attachment inbox outside the
|
||||
# workspace. Keep the public URL stable while scoping fallback lookup to
|
||||
# the requesting session's own attachment directory.
|
||||
try:
|
||||
from api.upload import _session_attachment_dir
|
||||
|
||||
attachment_target = safe_resolve(_session_attachment_dir(sid), rel)
|
||||
except Exception:
|
||||
return None
|
||||
if attachment_target.exists() and attachment_target.is_file():
|
||||
return attachment_target
|
||||
return None
|
||||
|
||||
|
||||
def _handle_file_raw(handler, parsed):
|
||||
qs = parse_qs(parsed.query)
|
||||
sid = qs.get("session_id", [""])[0]
|
||||
@@ -6375,8 +6399,8 @@ def _handle_file_raw(handler, parsed):
|
||||
return bad(handler, "Session not found", 404)
|
||||
rel = qs.get("path", [""])[0]
|
||||
force_download = qs.get("download", [""])[0] == "1"
|
||||
target = safe_resolve(Path(s.workspace), rel)
|
||||
if not target.exists() or not target.is_file():
|
||||
target = _file_raw_target(s, sid, rel)
|
||||
if target is None:
|
||||
return j(handler, {"error": "not found"}, status=404)
|
||||
ext = target.suffix.lower()
|
||||
mime = MIME_MAP.get(ext, "application/octet-stream")
|
||||
|
||||
+38
-4
@@ -1831,7 +1831,32 @@ def _maybe_schedule_title_refresh(session, put_event, agent):
|
||||
).start()
|
||||
|
||||
|
||||
def _sanitize_messages_for_api(messages):
|
||||
def _strip_native_image_parts_from_content(content):
|
||||
"""Return provider-safe content with native image parts removed.
|
||||
|
||||
Text-only provider endpoints (for example DeepSeek/OpenAI-compatible text
|
||||
models) reject historical OpenAI-style ``image_url`` parts before the agent
|
||||
can recover. When WebUI is configured for text-mode image handling, preserve
|
||||
textual content from mixed content arrays and drop only the native image
|
||||
blocks from replayed history.
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
clean_parts = []
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get('type') == 'image_url' or 'image_url' in part:
|
||||
continue
|
||||
clean_parts.append(copy.deepcopy(part))
|
||||
if not clean_parts:
|
||||
return ''
|
||||
if len(clean_parts) == 1 and clean_parts[0].get('type') == 'text':
|
||||
return str(clean_parts[0].get('text') or '')
|
||||
return clean_parts
|
||||
|
||||
|
||||
def _sanitize_messages_for_api(messages, *, cfg: dict = None):
|
||||
"""Return a deep copy of messages with only API-safe fields.
|
||||
|
||||
The webui stores extra metadata on messages (attachments, timestamp, _ts)
|
||||
@@ -1843,7 +1868,14 @@ def _sanitize_messages_for_api(messages):
|
||||
(Mercury-2/Inception, newer OpenAI models) reject histories containing dangling
|
||||
tool results with a 400 error: "Message has tool role, but there was no previous
|
||||
assistant message with a tool call."
|
||||
|
||||
If ``agent.image_input_mode`` resolves to ``text``, native historical
|
||||
``image_url`` content parts are stripped too. Current-turn uploads already
|
||||
respect text mode in ``_build_native_multimodal_message``; this closes the
|
||||
remaining replay gap where an older native image in the saved transcript kept
|
||||
causing 400s on every later text-only turn (#2297).
|
||||
"""
|
||||
strip_native_images = cfg is not None and _resolve_image_input_mode(cfg) == "text"
|
||||
# First pass: collect all tool_call_ids declared by assistant messages.
|
||||
# Handles both OpenAI ('id') and Anthropic ('call_id') field names.
|
||||
valid_tool_call_ids: set = set()
|
||||
@@ -1872,6 +1904,8 @@ def _sanitize_messages_for_api(messages):
|
||||
# Orphaned tool result — skip to avoid 400 from strict providers.
|
||||
continue
|
||||
sanitized = {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS}
|
||||
if strip_native_images and 'content' in sanitized:
|
||||
sanitized['content'] = _strip_native_image_parts_from_content(sanitized.get('content'))
|
||||
if sanitized.get('role'):
|
||||
clean.append(sanitized)
|
||||
return clean
|
||||
@@ -3515,7 +3549,7 @@ def _run_agent_streaming(
|
||||
result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages),
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
)
|
||||
@@ -3726,7 +3760,7 @@ def _run_agent_streaming(
|
||||
_heal_result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages),
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
)
|
||||
@@ -4505,7 +4539,7 @@ def _run_agent_streaming(
|
||||
_heal_result = _heal_agent.run_conversation(
|
||||
user_message=user_message,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages),
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
)
|
||||
|
||||
@@ -78,7 +78,10 @@ while WebUI still has multiple overlapping state stores.
|
||||
assistant just acted.
|
||||
5. **Replay is idempotent.** Replaying a run from a cursor must not duplicate
|
||||
transcript rows, thinking content, interim assistant text, tool cards, or
|
||||
compression cards.
|
||||
compression cards. Replayed long-task events should enter the same
|
||||
browser-facing timeline renderer as live SSE events so recovery does not
|
||||
downgrade a structured Thinking / progress / tool / compression turn into a
|
||||
separate flattened presentation.
|
||||
6. **Compression is not current intent.** Automatic compression summaries and
|
||||
reference cards are recovery/handoff material. They must not be treated as a
|
||||
new user request, active-turn content, or the default visible explanation for
|
||||
@@ -102,6 +105,8 @@ context reconstruction, or session metadata:
|
||||
- What happens after browser refresh, session switch, SSE reconnect, and WebUI
|
||||
restart?
|
||||
- Does replay rebuild the same scene without duplicates?
|
||||
- Does replay use the same timeline-rendering path as live SSE for thinking,
|
||||
interim assistant text, tool cards, compression cards, and terminal states?
|
||||
- Can this change move a session in the sidebar without meaningful user or
|
||||
assistant activity?
|
||||
- Can automatic compression or recovery text become visible active-turn content?
|
||||
@@ -147,4 +152,3 @@ The two documents should be read together:
|
||||
4. If #1925 introduces a new adapter-backed runtime layer, update this RFC or
|
||||
replace it with the accepted implementation contract so these invariants do
|
||||
not live only in historical discussion.
|
||||
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ function _setWorkspacePanelMode(mode){
|
||||
// Persist open/closed across refreshes (browse/preview → open; closed → closed)
|
||||
// Do NOT overwrite the user's "keep open" preference — only track runtime state
|
||||
// so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting.
|
||||
localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');
|
||||
try{localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');}catch(_){}
|
||||
layout.classList.toggle('workspace-panel-collapsed',!open);
|
||||
if(_isCompactWorkspaceViewport()){
|
||||
panel.classList.toggle('mobile-open',open);
|
||||
|
||||
+1
-1
@@ -424,7 +424,7 @@ async function _applyManualCompressionResult(data, focusTopic, visibleCount, com
|
||||
S.messages=data.session.messages||[];
|
||||
S.toolCalls=data.session.tool_calls||[];
|
||||
clearLiveToolCards();
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
syncTopbar();
|
||||
renderMessages();
|
||||
|
||||
+47
-3
@@ -731,6 +731,10 @@ const LOCALES = {
|
||||
transcript: 'Transcript',
|
||||
download_transcript: 'Download as Markdown',
|
||||
import: 'Import',
|
||||
export_session_json: 'JSON',
|
||||
export_session_json_tooltip: 'Export full session as JSON',
|
||||
import_session_json_tooltip: 'Import session from JSON',
|
||||
clear_conversation_btn_tooltip: 'Clear all messages in this conversation',
|
||||
// Settings detail
|
||||
settings_label_sound: 'Notification sound',
|
||||
settings_desc_sound: 'Play a sound when the assistant finishes a response.',
|
||||
@@ -1913,6 +1917,10 @@ const LOCALES = {
|
||||
transcript: 'Trascrizione',
|
||||
download_transcript: 'Scarica come Markdown',
|
||||
import: 'Importa',
|
||||
export_session_json: 'JSON',
|
||||
export_session_json_tooltip: 'Esporta sessione completa come JSON',
|
||||
import_session_json_tooltip: 'Importa sessione da JSON',
|
||||
clear_conversation_btn_tooltip: 'Cancella tutti i messaggi in questa conversazione',
|
||||
// Settings detail
|
||||
settings_label_sound: 'Suono notifica',
|
||||
settings_desc_sound: 'Riproduci un suono quando l\'assistente termina una risposta.',
|
||||
@@ -3100,6 +3108,10 @@ const LOCALES = {
|
||||
transcript: 'トランスクリプト',
|
||||
download_transcript: 'Markdown としてダウンロード',
|
||||
import: 'インポート',
|
||||
export_session_json: 'JSON',
|
||||
export_session_json_tooltip: 'セッション全体をJSONとしてエクスポート',
|
||||
import_session_json_tooltip: 'JSONからセッションをインポート',
|
||||
clear_conversation_btn_tooltip: 'この会話のすべてのメッセージをクリア',
|
||||
// Settings detail
|
||||
settings_label_sound: '通知音',
|
||||
settings_desc_sound: 'アシスタントが応答を完了したときに音を鳴らします。',
|
||||
@@ -4072,6 +4084,10 @@ const LOCALES = {
|
||||
transcript: 'Транскрипт',
|
||||
download_transcript: 'Скачать как Markdown',
|
||||
import: 'Импорт',
|
||||
export_session_json: 'JSON',
|
||||
export_session_json_tooltip: 'Экспортировать сессию как JSON',
|
||||
import_session_json_tooltip: 'Импортировать сессию из JSON',
|
||||
clear_conversation_btn_tooltip: 'Очистить все сообщения в этой беседе',
|
||||
settings_label_sound: 'Звук уведомления',
|
||||
settings_desc_sound: 'Проигрывать звук, когда помощник завершает ответ.',
|
||||
settings_label_notifications: 'Уведомления браузера',
|
||||
@@ -5193,6 +5209,10 @@ const LOCALES = {
|
||||
transcript: 'Transcripción',
|
||||
download_transcript: 'Descargar como Markdown',
|
||||
import: 'Importar',
|
||||
export_session_json: 'JSON',
|
||||
export_session_json_tooltip: 'Exportar sesión completa como JSON',
|
||||
import_session_json_tooltip: 'Importar sesión desde JSON',
|
||||
clear_conversation_btn_tooltip: 'Borrar todos los mensajes de esta conversación',
|
||||
// Settings detail
|
||||
settings_label_sound: 'Sonido de notificación',
|
||||
settings_desc_sound: 'Reproduce un sonido cuando el asistente termina una respuesta.',
|
||||
@@ -6287,6 +6307,10 @@ const LOCALES = {
|
||||
transcript: 'Protokoll',
|
||||
download_transcript: 'Als Markdown herunterladen',
|
||||
import: 'Importieren',
|
||||
export_session_json: 'JSON',
|
||||
export_session_json_tooltip: 'Gesamte Sitzung als JSON exportieren',
|
||||
import_session_json_tooltip: 'Sitzung aus JSON importieren',
|
||||
clear_conversation_btn_tooltip: 'Alle Nachrichten in dieser Konversation löschen',
|
||||
// Settings detail
|
||||
settings_label_sound: 'Benachrichtigungston',
|
||||
settings_desc_sound: 'Spielt einen Ton ab, wenn der Assistent eine Antwort beendet.',
|
||||
@@ -6581,9 +6605,9 @@ const LOCALES = {
|
||||
session_toolsets_cleared: 'Toolsets cleared — using global config', // TODO: translate
|
||||
session_toolsets_failed: 'Failed to update toolsets: ', // TODO: translate
|
||||
session_time_unknown: 'Unbekannt',
|
||||
session_time_minutes_ago: 'Vor {n} Minuten',
|
||||
session_time_hours_ago: 'Vor {n} Stunden',
|
||||
session_time_days_ago: 'Vor {n} Tagen',
|
||||
session_time_minutes_ago: (n) => `Vor ${n} Minuten`,
|
||||
session_time_hours_ago: (n) => `Vor ${n} Stunden`,
|
||||
session_time_days_ago: (n) => `Vor ${n} Tagen`,
|
||||
session_time_last_week: 'Letzte Woche',
|
||||
session_time_bucket_today: 'Heute',
|
||||
session_time_bucket_yesterday: 'Gestern',
|
||||
@@ -7434,6 +7458,10 @@ const LOCALES = {
|
||||
transcript: '记录',
|
||||
download_transcript: '下载为 Markdown',
|
||||
import: '导入',
|
||||
export_session_json: 'JSON',
|
||||
export_session_json_tooltip: '将会话完整导出为 JSON',
|
||||
import_session_json_tooltip: '从 JSON 导入会话',
|
||||
clear_conversation_btn_tooltip: '清空此会话中的所有消息',
|
||||
editing: '编辑中',
|
||||
empty_title: '有什么可以帮您?',
|
||||
empty_subtitle: '随时提问、运行命令、浏览文件或管理定时任务。',
|
||||
@@ -8545,6 +8573,10 @@ const LOCALES = {
|
||||
transcript: '\u8a18\u9304',
|
||||
download_transcript: '\u4e0b\u8f09\u8a18\u9304',
|
||||
import: '\u5c0e\u5165',
|
||||
export_session_json: 'JSON',
|
||||
export_session_json_tooltip: '\u5c07\u6703\u8a71\u5b8c\u6574\u532f\u51fa\u70ba JSON',
|
||||
import_session_json_tooltip: '\u5f9e JSON \u532f\u5165\u6703\u8a71',
|
||||
clear_conversation_btn_tooltip: '\u6e05\u7a7a\u6b64\u6703\u8a71\u4e2d\u7684\u6240\u6709\u8a0a\u606f',
|
||||
editing: '\u7de8\u8f2f\u4e2d',
|
||||
empty_title: '有什麼可以幫忙?',
|
||||
empty_subtitle: '點擊上方按鈕開始對話',
|
||||
@@ -9834,6 +9866,10 @@ const LOCALES = {
|
||||
transcript: 'Transcrição',
|
||||
download_transcript: 'Baixar como Markdown',
|
||||
import: 'Importar',
|
||||
export_session_json: 'JSON',
|
||||
export_session_json_tooltip: 'Exportar sessão completa como JSON',
|
||||
import_session_json_tooltip: 'Importar sessão de JSON',
|
||||
clear_conversation_btn_tooltip: 'Limpar todas as mensagens nesta conversa',
|
||||
// Settings detail
|
||||
settings_label_sound: 'Som de notificação',
|
||||
settings_desc_sound: 'Tocar som quando assistente finalizar resposta.',
|
||||
@@ -10918,6 +10954,10 @@ const LOCALES = {
|
||||
transcript: '대화 기록',
|
||||
download_transcript: 'Download as Markdown',
|
||||
import: '가져오기',
|
||||
export_session_json: 'JSON',
|
||||
export_session_json_tooltip: '전체 세션을 JSON으로 내보내기',
|
||||
import_session_json_tooltip: 'JSON에서 세션 가져오기',
|
||||
clear_conversation_btn_tooltip: '이 대화의 모든 메시지 지우기',
|
||||
// Settings detail
|
||||
settings_label_sound: '알림음',
|
||||
settings_desc_sound: 'Assistant 응답이 끝나면 소리를 재생합니다.',
|
||||
@@ -12017,6 +12057,10 @@ const LOCALES = {
|
||||
transcript: 'Transcription',
|
||||
download_transcript: 'Télécharger en Markdown',
|
||||
import: 'Importer',
|
||||
export_session_json: 'JSON',
|
||||
export_session_json_tooltip: 'Exporter la session complète en JSON',
|
||||
import_session_json_tooltip: 'Importer une session depuis JSON',
|
||||
clear_conversation_btn_tooltip: 'Effacer tous les messages de cette conversation',
|
||||
settings_label_sound: 'Son de notification',
|
||||
settings_desc_sound: 'Jouez un son lorsque l\'assistant termine une réponse.',
|
||||
tts_listen: 'Écouter',
|
||||
|
||||
+3
-3
@@ -816,9 +816,9 @@
|
||||
</div>
|
||||
<div class="hermes-action-grid">
|
||||
<button class="settings-action-btn" id="btnDownload" title="Download as Markdown" data-i18n-title="download_transcript"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <span data-i18n="transcript">Transcript</span></button>
|
||||
<button class="settings-action-btn" id="btnExportJSON" title="Export full session as JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg> JSON</button>
|
||||
<button class="settings-action-btn" id="btnImportJSON" title="Import session from JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
|
||||
<button class="settings-action-btn danger" id="btnClearConvModal" onclick="clearConversation()" title="Clear all messages in this conversation"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 1 2 2 2v2"/></svg> Clear</button>
|
||||
<button class="settings-action-btn" id="btnExportJSON" title="Export full session as JSON" data-i18n-title="export_session_json_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg> <span data-i18n="export_session_json">JSON</span></button>
|
||||
<button class="settings-action-btn" id="btnImportJSON" title="Import session from JSON" data-i18n-title="import_session_json_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
|
||||
<button class="settings-action-btn danger" id="btnClearConvModal" onclick="clearConversation()" title="Clear all messages in this conversation" data-i18n-title="clear_conversation_btn_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 1 2 2 2v2"/></svg> <span data-i18n="clear">Clear</span></button>
|
||||
</div>
|
||||
<input type="file" id="importFileInput" accept=".json" style="display:none">
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -1454,7 +1454,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const _prevCost=(S.session&&S.session.estimated_cost)||0;
|
||||
S.session=d.session;S.messages=d.session.messages||[];if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
|
||||
if(S.session&&S.session.session_id){
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
}
|
||||
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
|
||||
@@ -1824,7 +1824,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
S.session=session;S.messages=(session.messages||[]).filter(m=>m&&m.role);
|
||||
if(S.session&&S.session.session_id){
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
}
|
||||
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
|
||||
|
||||
+16
-2
@@ -170,6 +170,14 @@ function _clearSessionCompletionUnread(sid) {
|
||||
_saveSessionCompletionUnread();
|
||||
}
|
||||
|
||||
function _clearSessionViewedCount(sid) {
|
||||
if (!sid) return;
|
||||
const counts = _getSessionViewedCounts();
|
||||
if (!Object.prototype.hasOwnProperty.call(counts, sid)) return;
|
||||
delete counts[sid];
|
||||
_saveSessionViewedCounts();
|
||||
}
|
||||
|
||||
function _hasSessionCompletionUnread(sid) {
|
||||
if (!sid) return false;
|
||||
return Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), sid);
|
||||
@@ -416,7 +424,7 @@ async function newSession(flash, options={}){
|
||||
S.session=data.session;S.messages=data.session.messages||[];
|
||||
S.lastUsage={...(data.session.last_usage||{})};
|
||||
if(flash)S.session._flash=true;
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
_setActiveSessionUrl(S.session.session_id);
|
||||
_setSessionViewedCount(S.session.session_id, S.session.message_count || 0);
|
||||
// Sync chat-header dropdown to the session's model so the UI reflects
|
||||
@@ -526,7 +534,7 @@ async function loadSession(sid){
|
||||
if(typeof syncTopbar==='function') syncTopbar();
|
||||
_setSessionViewedCount(S.session.session_id, Number(data.session.message_count || 0));
|
||||
_clearSessionCompletionUnread(S.session.session_id);
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
_setActiveSessionUrl(S.session.session_id);
|
||||
|
||||
const activeStreamId=S.session.active_stream_id||null;
|
||||
@@ -810,6 +818,12 @@ function _clearHandoffStorageForSession(sid) {
|
||||
_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT, null);
|
||||
_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT, null);
|
||||
} catch {}
|
||||
// Session deletion should also prune per-session tracking maps. Otherwise
|
||||
// heavy users accumulate one localStorage entry per deleted session forever,
|
||||
// which increases quota pressure and can make future UI persistence fail.
|
||||
try { _clearSessionViewedCount(sid); } catch {}
|
||||
try { _clearSessionCompletionUnread(sid); } catch {}
|
||||
try { _forgetObservedStreamingSession(sid); } catch {}
|
||||
}
|
||||
|
||||
function _getHandoffDismissedAt(sid) {
|
||||
|
||||
+22
-15
@@ -39,28 +39,35 @@ const SHELL_ASSETS = [
|
||||
'./manifest.json',
|
||||
];
|
||||
|
||||
// Install: pre-cache the app shell
|
||||
function deleteOldShellCaches() {
|
||||
return caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Install: prune old shell caches first, then pre-cache the app shell. Doing
|
||||
// this before caches.open(CACHE_NAME) avoids a temporary double-cache window on
|
||||
// quota-sensitive browsers during frequent version bumps.
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(SHELL_ASSETS).catch((err) => {
|
||||
// Non-fatal: if any asset fails, still activate
|
||||
console.warn('[sw] Shell pre-cache partial failure:', err);
|
||||
});
|
||||
})
|
||||
deleteOldShellCaches().then(() =>
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(SHELL_ASSETS).catch((err) => {
|
||||
// Non-fatal: if any asset fails, still activate
|
||||
console.warn('[sw] Shell pre-cache partial failure:', err);
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: clean up old caches
|
||||
// Activate: keep the old-cache cleanup as a safety net in case install was
|
||||
// interrupted or an older worker was already waiting.
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
);
|
||||
event.waitUntil(deleteOldShellCaches());
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
|
||||
+5
-2
@@ -2614,7 +2614,10 @@ function renderMd(raw){
|
||||
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${inlineMd(c.trim())}</th>`).join('');
|
||||
const header=`<tr>${parseHeader(rows[0])}</tr>`;
|
||||
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
|
||||
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
|
||||
// Surround with blank lines so the final paragraph splitter treats the
|
||||
// generated table as its own block even when the regex consumes one of the
|
||||
// markdown block's trailing newlines.
|
||||
return `\n\n<table><thead>${header}</thead><tbody>${body}</tbody></table>\n\n`;
|
||||
});
|
||||
// #487: Outer image pass — handles  in plain paragraphs (outside tables/lists).
|
||||
// Runs AFTER the table pass (images in table cells are handled by inlineMd() above).
|
||||
@@ -2757,7 +2760,7 @@ function renderMd(raw){
|
||||
return '\x00E'+(_pre_stash.length-1)+'\x00';
|
||||
});
|
||||
const parts=s.split(/\n{2,}/);
|
||||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|table|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||||
s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]);
|
||||
// ── Restore MEDIA stash → inline images or download links ─────────────────
|
||||
s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{
|
||||
|
||||
@@ -171,6 +171,36 @@ def pytest_configure(config):
|
||||
# imports trigger botocore initialisation.
|
||||
os.environ.setdefault("AWS_EC2_METADATA_DISABLED", "true")
|
||||
|
||||
# ── Permanent os.execv guard for the pytest session ────────────────────────
|
||||
# Several tests in tests/test_update_banner_fixes.py exercise
|
||||
# api.updates._schedule_restart(), which spawns a DAEMON thread that sleeps
|
||||
# for a short delay and then calls ``os.execv(sys.executable, sys.argv)``.
|
||||
# Those tests monkeypatch ``os.execv`` to a no-op for the test scope, but
|
||||
# monkeypatch teardown happens at test exit — if the daemon thread has not
|
||||
# yet woken up by then (system load, GC pause, _apply_lock contention), the
|
||||
# real ``os.execv`` is restored before the thread fires it. The daemon then
|
||||
# REPLACES the pytest process image with a fresh ``pytest tests/ -q ...``
|
||||
# invocation, looking from the outside like pytest "hangs at 99%" and then
|
||||
# restarts the entire suite from 0% — a self-perpetuating loop.
|
||||
#
|
||||
# Daemon threads cannot be reliably joined from a test fixture (they live in
|
||||
# ``api.updates`` module scope), so the only safe answer is to render
|
||||
# ``os.execv`` permanently inert for the pytest session. Production code is
|
||||
# unaffected because production never imports this conftest.
|
||||
#
|
||||
# Tests that need to verify execv WAS called still monkeypatch it themselves
|
||||
# — their patched version takes precedence over this no-op wrapper for the
|
||||
# test's lifetime, and the no-op only kicks in after teardown for daemon
|
||||
# threads that wake up late.
|
||||
_real_execv = os.execv
|
||||
|
||||
def _pytest_session_safe_execv(_exe, _args): # pragma: no cover — never called in prod
|
||||
# Drop the call on the floor. A late-firing daemon thread from
|
||||
# _schedule_restart() must not be able to re-exec the pytest process.
|
||||
return None
|
||||
|
||||
os.execv = _pytest_session_safe_execv
|
||||
|
||||
# ── Hermetic network isolation ─────────────────────────────────────────────
|
||||
# Tests must not reach the public internet. Outbound to Anthropic / OpenAI /
|
||||
# Amazon / OpenRouter / etc. is forbidden by default. The test suite already
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _script(path):
|
||||
return (ROOT / path).read_text()
|
||||
|
||||
|
||||
def _assert_storage_setitem_guarded(src, needle):
|
||||
matches = [line.strip() for line in src.splitlines() if needle in line]
|
||||
assert matches, f"expected at least one {needle} write"
|
||||
for line in matches:
|
||||
assert line.startswith("try{localStorage.setItem("), (
|
||||
f"localStorage quota errors must not escape from {needle} writes: {line}"
|
||||
)
|
||||
assert "catch(_)" in line or "catch(e)" in line or "catch{}" in line
|
||||
|
||||
|
||||
def test_active_session_localstorage_writes_ignore_quota_errors():
|
||||
"""Session persistence writes are best-effort when the browser quota is full (#2386)."""
|
||||
for path in ["static/sessions.js", "static/commands.js", "static/messages.js"]:
|
||||
_assert_storage_setitem_guarded(
|
||||
_script(path),
|
||||
"localStorage.setItem('hermes-webui-session'",
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_panel_localstorage_write_ignores_quota_errors():
|
||||
"""Workspace panel state should not break UI toggles if localStorage throws (#2386)."""
|
||||
_assert_storage_setitem_guarded(
|
||||
_script("static/boot.js"),
|
||||
"localStorage.setItem('hermes-webui-workspace-panel'",
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Regression coverage for storage-pressure cleanup from issue #2389."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SW_SRC = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
|
||||
SESSIONS_SRC = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_block(src: str, name: str, window: int = 1600) -> str:
|
||||
idx = src.find(f"function {name}(")
|
||||
assert idx != -1, f"missing function {name}"
|
||||
return src[idx : idx + window]
|
||||
|
||||
|
||||
def test_service_worker_install_deletes_old_caches_before_opening_new_cache():
|
||||
install_idx = SW_SRC.find("self.addEventListener('install'")
|
||||
assert install_idx != -1, "service worker must define an install handler"
|
||||
install_block = SW_SRC[install_idx : SW_SRC.find("self.addEventListener('activate'", install_idx)]
|
||||
cleanup_idx = install_block.find("deleteOldShellCaches().then")
|
||||
open_idx = install_block.find("caches.open(CACHE_NAME)")
|
||||
assert cleanup_idx != -1, "install must delete stale shell caches before pre-cache"
|
||||
assert open_idx != -1, "install must still pre-cache the current shell cache"
|
||||
assert cleanup_idx < open_idx, (
|
||||
"opening the new shell cache before deleting old ones creates a temporary "
|
||||
"double-cache window that increases quota pressure"
|
||||
)
|
||||
|
||||
|
||||
def test_service_worker_keeps_activate_cleanup_safety_net():
|
||||
activate_idx = SW_SRC.find("self.addEventListener('activate'")
|
||||
assert activate_idx != -1, "service worker must define an activate handler"
|
||||
activate_block = SW_SRC[activate_idx : activate_idx + 500]
|
||||
assert "event.waitUntil(deleteOldShellCaches())" in activate_block
|
||||
assert "self.clients.claim()" in activate_block
|
||||
|
||||
|
||||
def test_deleted_sessions_prune_all_session_tracking_maps():
|
||||
assert "const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';" in SESSIONS_SRC
|
||||
assert "const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';" in SESSIONS_SRC
|
||||
assert "const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming';" in SESSIONS_SRC
|
||||
assert "function _clearSessionViewedCount(sid)" in SESSIONS_SRC
|
||||
|
||||
clear_block = _function_block(SESSIONS_SRC, "_clearHandoffStorageForSession")
|
||||
assert "_clearSessionViewedCount(sid)" in clear_block
|
||||
assert "_clearSessionCompletionUnread(sid)" in clear_block
|
||||
assert "_forgetObservedStreamingSession(sid)" in clear_block
|
||||
|
||||
|
||||
def test_session_viewed_count_prune_is_best_effort_and_persists_when_changed():
|
||||
viewed_block = _function_block(SESSIONS_SRC, "_clearSessionViewedCount")
|
||||
assert "Object.prototype.hasOwnProperty.call(counts, sid)" in viewed_block
|
||||
assert "delete counts[sid]" in viewed_block
|
||||
assert "_saveSessionViewedCounts()" in viewed_block
|
||||
|
||||
clear_block = _function_block(SESSIONS_SRC, "_clearHandoffStorageForSession")
|
||||
assert "try { _clearSessionViewedCount(sid); } catch {}" in clear_block
|
||||
assert "try { _clearSessionCompletionUnread(sid); } catch {}" in clear_block
|
||||
assert "try { _forgetObservedStreamingSession(sid); } catch {}" in clear_block
|
||||
@@ -17,6 +17,7 @@ from api.streaming import (
|
||||
_attachment_name,
|
||||
_build_native_multimodal_message,
|
||||
_NATIVE_IMAGE_MAX_BYTES,
|
||||
_sanitize_messages_for_api,
|
||||
)
|
||||
from api.routes import _normalize_chat_attachments
|
||||
|
||||
@@ -318,6 +319,41 @@ class TestBuildNativeMultimodalMessage:
|
||||
assert data_url.startswith('data:image/png;base64,')
|
||||
assert len(result) == 2
|
||||
|
||||
def test_text_image_mode_strips_historical_image_url_parts(self):
|
||||
"""#2297: text-only providers must not replay old native image parts."""
|
||||
history = [
|
||||
{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'what is in this image?'},
|
||||
{'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,AAA='}},
|
||||
],
|
||||
'attachments': [{'name': 'photo.png'}],
|
||||
'timestamp': 123,
|
||||
},
|
||||
{'role': 'assistant', 'content': 'It is a chart.'},
|
||||
]
|
||||
cfg = {'agent': {'image_input_mode': 'text'}}
|
||||
|
||||
sanitized = _sanitize_messages_for_api(history, cfg=cfg)
|
||||
|
||||
assert sanitized[0] == {'role': 'user', 'content': 'what is in this image?'}
|
||||
assert 'image_url' not in str(sanitized)
|
||||
assert 'attachments' not in sanitized[0]
|
||||
assert sanitized[1] == {'role': 'assistant', 'content': 'It is a chart.'}
|
||||
|
||||
def test_native_image_mode_keeps_historical_image_url_parts(self):
|
||||
"""Vision-capable/native mode keeps existing multimodal history intact."""
|
||||
content = [
|
||||
{'type': 'text', 'text': 'describe'},
|
||||
{'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,AAA='}},
|
||||
]
|
||||
cfg = {'agent': {'image_input_mode': 'native'}}
|
||||
|
||||
sanitized = _sanitize_messages_for_api([{'role': 'user', 'content': content}], cfg=cfg)
|
||||
|
||||
assert sanitized == [{'role': 'user', 'content': content}]
|
||||
|
||||
def test_fake_png_rejected_by_magic_bytes(self):
|
||||
"""A file named .png that is not actually an image must be rejected."""
|
||||
with TemporaryDirectory() as d:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Regression guard for the pytest "hangs at 99% then restarts from 0%" loop.
|
||||
|
||||
Root cause documented in tests/conftest.py — daemon threads spawned by
|
||||
api.updates._schedule_restart() can fire os.execv() AFTER monkeypatch
|
||||
teardown restores the real os.execv, which re-execs the entire pytest
|
||||
process. The conftest installs a permanent no-op wrapper on os.execv that
|
||||
shadows any late-firing daemon thread.
|
||||
|
||||
This test pins the guard so a future conftest refactor can't silently
|
||||
remove it.
|
||||
"""
|
||||
import os
|
||||
|
||||
|
||||
def test_conftest_installs_permanent_execv_guard():
|
||||
"""os.execv must be replaced by the conftest's safe no-op wrapper."""
|
||||
# The wrapper is named `_pytest_session_safe_execv` in conftest.py.
|
||||
# Verify the module attribute now points to that wrapper, not the real
|
||||
# libc-bound function.
|
||||
assert os.execv.__name__ == '_pytest_session_safe_execv', (
|
||||
f"os.execv must be the conftest-installed pytest-safe no-op, but "
|
||||
f"resolves to {os.execv!r}. Did a recent conftest refactor remove "
|
||||
f"the guard? See conftest.py § 'Permanent os.execv guard for the "
|
||||
f"pytest session' — without it, late-firing _schedule_restart "
|
||||
f"daemon threads re-exec pytest and the suite loops forever."
|
||||
)
|
||||
|
||||
|
||||
def test_safe_execv_returns_none_does_not_exec():
|
||||
"""The wrapper must be a true no-op — it must not raise, exec, or block."""
|
||||
# Pass deliberately bogus args to confirm the wrapper drops them rather
|
||||
# than passing them through to the real execv.
|
||||
result = os.execv('/nonexistent/binary/path/that/should/not/be/executed',
|
||||
['/nonexistent/binary/path/that/should/not/be/executed'])
|
||||
assert result is None
|
||||
@@ -187,6 +187,37 @@ class TestRendererSanitization:
|
||||
|
||||
class TestCommonLLMShapes:
|
||||
|
||||
def test_commonmark_table_is_not_wrapped_in_paragraph(self, driver_path):
|
||||
src = (
|
||||
"| 升级时段 | 人数 |\n"
|
||||
"|---------|------|\n"
|
||||
"| 5/15(发布当天) | ~30 人 |\n"
|
||||
"| 5/16(今天) | ~10 人 |"
|
||||
)
|
||||
out = _render(driver_path, src)
|
||||
assert "<table><thead>" in out
|
||||
assert "<th>升级时段</th>" in out
|
||||
assert "<td>5/15(发布当天)</td>" in out
|
||||
assert "<td>~10 人</td>" in out
|
||||
assert "<p><table" not in out, (
|
||||
f"Markdown tables are block elements and must not be paragraph-wrapped: {out!r}"
|
||||
)
|
||||
|
||||
def test_table_between_paragraphs_stays_block_level(self, driver_path):
|
||||
src = (
|
||||
"Before the table.\n\n"
|
||||
"| Key | Value |\n"
|
||||
"| --- | --- |\n"
|
||||
"| A | B |\n\n"
|
||||
"After the table."
|
||||
)
|
||||
out = _render(driver_path, src)
|
||||
assert "<p>Before the table.</p>" in out
|
||||
assert "<table><thead>" in out
|
||||
assert "<p>After the table.</p>" in out
|
||||
assert "<p><table" not in out
|
||||
assert "</table></p>" not in out
|
||||
|
||||
def test_strikethrough_outside_quote(self, driver_path):
|
||||
out = _render(driver_path, "This was ~~outdated~~ but is now fine.")
|
||||
assert "<del>outdated</del>" in out
|
||||
|
||||
@@ -35,3 +35,60 @@ def test_frontend_replay_cursor_uses_eventsource_last_event_id():
|
||||
assert "source.addEventListener(_runJournalEventName,_rememberRunJournalCursor)" in MESSAGES_SRC
|
||||
assert "after_seq=${encodeURIComponent(String(_runJournalReplayAfterSeq()))}" in MESSAGES_SRC
|
||||
assert "after_seq=0" not in MESSAGES_SRC
|
||||
|
||||
|
||||
def test_replayed_long_task_events_enter_the_same_live_timeline_handlers():
|
||||
"""Run-journal replay must not grow a parallel long-task renderer.
|
||||
|
||||
The run-state consistency contract depends on replayed journal events
|
||||
flowing through the same EventSource handlers as live streams. Otherwise a
|
||||
live long task can render as Thinking -> progress text -> tool cards, while
|
||||
the same journaled event sequence replays as a flattened or reordered scene.
|
||||
"""
|
||||
wire_pos = MESSAGES_SRC.index("function _wireSSE(source)")
|
||||
wire_block = MESSAGES_SRC[wire_pos : MESSAGES_SRC.index("async function _restoreSettledSession", wire_pos)]
|
||||
replay_events = [
|
||||
"reasoning",
|
||||
"interim_assistant",
|
||||
"tool",
|
||||
"tool_complete",
|
||||
"compressing",
|
||||
"compressed",
|
||||
"metering",
|
||||
"done",
|
||||
"apperror",
|
||||
]
|
||||
|
||||
for event_name in replay_events:
|
||||
assert f"source.addEventListener('{event_name}'" in wire_block, (
|
||||
f"{event_name} must be handled by the shared live/replay SSE pipeline"
|
||||
)
|
||||
|
||||
assert "updateThinking(" in wire_block, "reasoning replay should use the live Thinking card path"
|
||||
assert "appendLiveToolCard(tc)" in wire_block, "tool replay should use live tool-card rendering"
|
||||
assert "setCompressionUi({" in wire_block, "compression replay should use the compression card path"
|
||||
assert "_runJournalReplayParams()" in MESSAGES_SRC, "replay attachments should enter _wireSSE via EventSource"
|
||||
|
||||
|
||||
def test_run_journal_cursor_tracks_every_long_task_timeline_event():
|
||||
"""Every user-visible long-task event needs cursor tracking for parity replay."""
|
||||
cursor_loop_pos = MESSAGES_SRC.index("for(const _runJournalEventName of [")
|
||||
cursor_loop = MESSAGES_SRC[cursor_loop_pos : MESSAGES_SRC.index("]", cursor_loop_pos)]
|
||||
timeline_events = [
|
||||
"token",
|
||||
"interim_assistant",
|
||||
"reasoning",
|
||||
"tool",
|
||||
"tool_complete",
|
||||
"compressing",
|
||||
"compressed",
|
||||
"metering",
|
||||
"done",
|
||||
"apperror",
|
||||
"cancel",
|
||||
]
|
||||
|
||||
for event_name in timeline_events:
|
||||
assert f"'{event_name}'" in cursor_loop, (
|
||||
f"{event_name} must advance the replay cursor to avoid duplicate timeline replay"
|
||||
)
|
||||
|
||||
@@ -369,6 +369,44 @@ def test_pre_compression_snapshot_hidden_from_active_sidebar_but_file_remains(mo
|
||||
assert [row["session_id"] for row in rows] == ["new_sid"]
|
||||
|
||||
|
||||
def test_fuller_pre_compression_snapshot_replaces_shorter_visible_segment(monkeypatch):
|
||||
"""If the hidden snapshot has the fuller transcript, keep it reachable.
|
||||
|
||||
Auto-compression can leave a visible continuation segment in the sidebar
|
||||
while the fuller transcript remains on disk marked as a pre-compression
|
||||
snapshot. In that case the default session list should prefer the fuller
|
||||
transcript so the conversation does not look like recent messages vanished.
|
||||
"""
|
||||
snapshot = Session(
|
||||
session_id="full_parent",
|
||||
title="Long Conversation",
|
||||
messages=[
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "second"},
|
||||
{"role": "user", "content": "latest user"},
|
||||
{"role": "assistant", "content": "latest answer"},
|
||||
],
|
||||
pre_compression_snapshot=True,
|
||||
updated_at=300.0,
|
||||
)
|
||||
continuation = Session(
|
||||
session_id="short_child",
|
||||
title="Long Conversation",
|
||||
messages=[{"role": "user", "content": "first"}],
|
||||
parent_session_id="full_parent",
|
||||
updated_at=400.0,
|
||||
)
|
||||
snapshot.save()
|
||||
continuation.save()
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert [row["session_id"] for row in rows] == ["full_parent"]
|
||||
assert rows[0]["message_count"] == 4
|
||||
assert rows[0]["pre_compression_snapshot"] is True
|
||||
|
||||
|
||||
def test_session_save_does_not_persist_metadata_message_count_hint():
|
||||
s = Session(
|
||||
session_id="sess_private_hint",
|
||||
|
||||
@@ -169,3 +169,12 @@ def test_relative_time_strings_are_localized_in_english_and_spanish_bundles():
|
||||
"session_time_bucket_older",
|
||||
):
|
||||
assert key in I18N_JS
|
||||
|
||||
|
||||
def test_german_relative_time_translations_interpolate_numbers():
|
||||
assert "session_time_minutes_ago: (n) => `Vor ${n} Minuten`" in I18N_JS
|
||||
assert "session_time_hours_ago: (n) => `Vor ${n} Stunden`" in I18N_JS
|
||||
assert "session_time_days_ago: (n) => `Vor ${n} Tagen`" in I18N_JS
|
||||
assert "session_time_minutes_ago: 'Vor {n} Minuten'" not in I18N_JS
|
||||
assert "session_time_hours_ago: 'Vor {n} Stunden'" not in I18N_JS
|
||||
assert "session_time_days_ago: 'Vor {n} Tagen'" not in I18N_JS
|
||||
|
||||
@@ -231,7 +231,7 @@ class TestRepairStalePendingNoDeadlock:
|
||||
class TestDraftRecovery:
|
||||
"""When no core transcript exists, the pending user message is restored as
|
||||
a recovered user turn (_recovered=True) and the error marker says
|
||||
'Previous turn did not complete.' — NOT 'preserved as a draft'."""
|
||||
a clear restart interruption marker — NOT 'preserved as a draft'."""
|
||||
|
||||
def test_pending_message_recovered_as_user_turn(self, hermes_home, monkeypatch):
|
||||
"""When core transcript is missing, the pending_user_message is appended
|
||||
@@ -310,7 +310,10 @@ class TestDraftRecovery:
|
||||
assert "preserved as a draft" not in content, (
|
||||
f"Error marker should not say 'preserved as a draft', got: {content}"
|
||||
)
|
||||
assert "Previous turn did not complete" in content
|
||||
assert "Response interrupted" in content
|
||||
assert "WebUI process restarted" in content
|
||||
assert "user message above was preserved" in content
|
||||
assert error_msgs[0].get("type") == "interrupted"
|
||||
|
||||
def test_pending_attachments_recovered(self, hermes_home, monkeypatch):
|
||||
"""Attachments on the pending message are carried over to the recovered turn."""
|
||||
@@ -604,7 +607,9 @@ class TestNonEmptyMessagesPendingCleared:
|
||||
# Exactly one error marker
|
||||
error_msgs = [m for m in s.messages if m.get("_error")]
|
||||
assert len(error_msgs) == 1
|
||||
assert "Previous turn did not complete" in error_msgs[0]["content"]
|
||||
assert "Response interrupted" in error_msgs[0]["content"]
|
||||
assert "WebUI process restarted" in error_msgs[0]["content"]
|
||||
assert error_msgs[0].get("type") == "interrupted"
|
||||
|
||||
# Pending fields fully cleared
|
||||
assert s.pending_user_message is None
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_raw_endpoint_path_traversal_blocked(cleanup_test_sessions):
|
||||
get_raw(f"/api/file/raw?session_id={sid}&path=../../etc/passwd")
|
||||
assert False
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code in (400, 500)
|
||||
assert e.code in (400, 404, 500)
|
||||
|
||||
def test_raw_endpoint_missing_file_returns_404(cleanup_test_sessions):
|
||||
sid, _ = make_session_tracked(cleanup_test_sessions)
|
||||
|
||||
@@ -415,10 +415,15 @@ def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path,
|
||||
seen_env = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
from api.config import _thread_ctx
|
||||
|
||||
skill_module = sys.modules.get("tools.skills_tool")
|
||||
thread_env = getattr(_thread_ctx, "env", {})
|
||||
EnvAssertingAgent.seen_env = {
|
||||
"HERMES_HOME": os.environ.get("HERMES_HOME"),
|
||||
"HERMES_TEST_PROFILE_ENV": os.environ.get("HERMES_TEST_PROFILE_ENV"),
|
||||
"THREAD_HERMES_HOME": thread_env.get("HERMES_HOME"),
|
||||
"THREAD_HERMES_TEST_PROFILE_ENV": thread_env.get("HERMES_TEST_PROFILE_ENV"),
|
||||
"SKILL_MODULE_HOME": getattr(skill_module, "HERMES_HOME", None),
|
||||
"SKILL_MODULE_DIR": getattr(skill_module, "SKILLS_DIR", None),
|
||||
}
|
||||
@@ -461,6 +466,8 @@ def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path,
|
||||
assert EnvAssertingAgent.seen_env == {
|
||||
"HERMES_HOME": str(profile_home),
|
||||
"HERMES_TEST_PROFILE_ENV": "work-runtime",
|
||||
"THREAD_HERMES_HOME": str(profile_home),
|
||||
"THREAD_HERMES_TEST_PROFILE_ENV": "work-runtime",
|
||||
"SKILL_MODULE_HOME": profile_home,
|
||||
"SKILL_MODULE_DIR": profile_home / "skills",
|
||||
}
|
||||
|
||||
+32
-1
@@ -1,5 +1,5 @@
|
||||
"""Sprint 6 tests: Escape from editor, Phase D validation, HTML extraction, cron create, session export."""
|
||||
import json, uuid, pathlib, urllib.request, urllib.error
|
||||
import json, uuid, pathlib, urllib.parse, urllib.request, urllib.error
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
|
||||
from tests._pytest_port import BASE
|
||||
@@ -74,6 +74,37 @@ def test_file_raw_unknown_session():
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 404
|
||||
|
||||
def test_file_raw_serves_session_attachment_inbox(cleanup_test_sessions):
|
||||
from api.upload import _session_attachment_dir
|
||||
|
||||
sid, workspace = make_session_tracked(cleanup_test_sessions)
|
||||
filename = f"uploaded-chat-image-{uuid.uuid4().hex}.png"
|
||||
attachment_dir = _session_attachment_dir(sid)
|
||||
attachment_dir.mkdir(parents=True, exist_ok=True)
|
||||
payload = b"fake-png-bytes"
|
||||
(attachment_dir / filename).write_bytes(payload)
|
||||
|
||||
assert not (workspace / filename).exists(), "regression must exercise attachment fallback"
|
||||
raw, headers, status = get_raw(
|
||||
f"/api/file/raw?session_id={sid}&path={urllib.parse.quote(filename)}"
|
||||
)
|
||||
assert status == 200
|
||||
assert raw == payload
|
||||
assert "image/png" in headers.get("Content-Type", "")
|
||||
|
||||
def test_file_raw_attachment_fallback_rejects_traversal(cleanup_test_sessions):
|
||||
from api.upload import _session_attachment_dir
|
||||
|
||||
sid, _ = make_session_tracked(cleanup_test_sessions)
|
||||
attachment_dir = _session_attachment_dir(sid)
|
||||
attachment_dir.mkdir(parents=True, exist_ok=True)
|
||||
(attachment_dir / "safe.txt").write_text("safe", encoding="utf-8")
|
||||
try:
|
||||
get_raw(f"/api/file/raw?session_id={sid}&path={urllib.parse.quote('../../safe.txt')}")
|
||||
assert False, "Expected 404"
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 404
|
||||
|
||||
# ── Cron create ──
|
||||
|
||||
def test_cron_create_requires_prompt():
|
||||
|
||||
@@ -502,6 +502,64 @@ class TestBackgroundTitleProfileRouting(unittest.TestCase):
|
||||
self.assertEqual(getattr(fake_skill_module, 'SKILLS_DIR'), 'default-home/skills')
|
||||
self.assertEqual(mock_session.title, 'Profile Routed Title')
|
||||
|
||||
def test_background_profile_env_routes_load_config_and_provider_credentials(self):
|
||||
"""Hybrid worker env must satisfy config and os.getenv provider-key readers."""
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
import api.profiles as profiles
|
||||
from api.config import _thread_ctx
|
||||
try:
|
||||
from hermes_cli import config as hermes_config
|
||||
except ModuleNotFoundError:
|
||||
pytest.skip('hermes_cli is not installed in this CI environment')
|
||||
|
||||
session = types.SimpleNamespace(profile='work')
|
||||
captured = {}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
default_home = os.path.join(tmp, 'default-home')
|
||||
profile_home = os.path.join(tmp, 'profile-home')
|
||||
os.makedirs(default_home, exist_ok=True)
|
||||
os.makedirs(profile_home, exist_ok=True)
|
||||
with open(os.path.join(default_home, 'config.yaml'), 'w', encoding='utf-8') as f:
|
||||
f.write('model:\n provider: default-provider\n default: default-model\n')
|
||||
with open(os.path.join(profile_home, 'config.yaml'), 'w', encoding='utf-8') as f:
|
||||
f.write('model:\n provider: profile-provider\n default: profile-model\n')
|
||||
|
||||
with patch('api.profiles.get_hermes_home_for_profile', return_value=profile_home):
|
||||
runtime_env = {
|
||||
'PROFILE_ONLY_KEY': 'profile-only',
|
||||
'OPENROUTER_API_KEY': 'profile-openrouter-key',
|
||||
}
|
||||
with patch('api.profiles.get_profile_runtime_env', return_value=runtime_env):
|
||||
with patch.dict(os.environ, {'HERMES_HOME': default_home, 'OPENROUTER_API_KEY': 'default-openrouter-key'}, clear=False):
|
||||
os.environ.pop('PROFILE_ONLY_KEY', None)
|
||||
hermes_config._LOAD_CONFIG_CACHE.clear()
|
||||
with profiles.profile_env_for_background_worker(session, 'background title'):
|
||||
loaded = hermes_config.load_config()
|
||||
captured['loaded_provider'] = loaded.get('model', {}).get('provider')
|
||||
captured['process_home'] = os.environ.get('HERMES_HOME')
|
||||
captured['process_runtime_key'] = os.environ.get('PROFILE_ONLY_KEY')
|
||||
captured['provider_credential'] = os.getenv('OPENROUTER_API_KEY')
|
||||
captured['thread_home'] = getattr(_thread_ctx, 'env', {}).get('HERMES_HOME')
|
||||
captured['thread_runtime_key'] = getattr(_thread_ctx, 'env', {}).get('PROFILE_ONLY_KEY')
|
||||
captured['restored_home'] = os.environ.get('HERMES_HOME')
|
||||
captured['restored_runtime_key'] = os.environ.get('PROFILE_ONLY_KEY')
|
||||
captured['restored_provider_credential'] = os.environ.get('OPENROUTER_API_KEY')
|
||||
hermes_config._LOAD_CONFIG_CACHE.clear()
|
||||
|
||||
self.assertEqual(captured['loaded_provider'], 'profile-provider')
|
||||
self.assertEqual(captured['process_home'], profile_home)
|
||||
self.assertEqual(captured['process_runtime_key'], 'profile-only')
|
||||
self.assertEqual(captured['provider_credential'], 'profile-openrouter-key')
|
||||
self.assertEqual(captured['thread_home'], profile_home)
|
||||
self.assertEqual(captured['thread_runtime_key'], 'profile-only')
|
||||
self.assertEqual(captured['restored_home'], default_home)
|
||||
self.assertIsNone(captured['restored_runtime_key'])
|
||||
self.assertEqual(captured['restored_provider_credential'], 'default-openrouter-key')
|
||||
|
||||
|
||||
class TestAuxTitleTimeoutEdgeCases(unittest.TestCase):
|
||||
"""_aux_title_timeout must reject zero, negative, and non-numeric values."""
|
||||
|
||||
@@ -476,9 +476,12 @@ class TestUpdateSummaryRouteModelSelection:
|
||||
monkeypatch.setattr(cfg, 'get_effective_default_model', lambda: 'openai/test-main')
|
||||
|
||||
def fake_resolve_model_provider(model):
|
||||
thread_env = getattr(cfg._thread_ctx, 'env', {})
|
||||
captured['model_resolution_env'] = {
|
||||
'HERMES_HOME': os.environ.get('HERMES_HOME'),
|
||||
'HERMES_TEST_PROFILE_ENV': os.environ.get('HERMES_TEST_PROFILE_ENV'),
|
||||
'THREAD_HERMES_HOME': thread_env.get('HERMES_HOME'),
|
||||
'THREAD_HERMES_TEST_PROFILE_ENV': thread_env.get('HERMES_TEST_PROFILE_ENV'),
|
||||
}
|
||||
return model, 'openai', 'https://example.test/v1'
|
||||
|
||||
@@ -514,9 +517,12 @@ class TestUpdateSummaryRouteModelSelection:
|
||||
)
|
||||
|
||||
def fake_get_text_auxiliary_client(task, main_runtime=None):
|
||||
thread_env = getattr(cfg._thread_ctx, 'env', {})
|
||||
captured['aux_env'] = {
|
||||
'HERMES_HOME': os.environ.get('HERMES_HOME'),
|
||||
'HERMES_TEST_PROFILE_ENV': os.environ.get('HERMES_TEST_PROFILE_ENV'),
|
||||
'THREAD_HERMES_HOME': thread_env.get('HERMES_HOME'),
|
||||
'THREAD_HERMES_TEST_PROFILE_ENV': thread_env.get('HERMES_TEST_PROFILE_ENV'),
|
||||
'SKILL_MODULE_HOME': getattr(fake_skill_module, 'HERMES_HOME'),
|
||||
'SKILL_MODULE_DIR': getattr(fake_skill_module, 'SKILLS_DIR'),
|
||||
}
|
||||
@@ -564,10 +570,14 @@ class TestUpdateSummaryRouteModelSelection:
|
||||
assert captured['model_resolution_env'] == {
|
||||
'HERMES_HOME': str(profile_home),
|
||||
'HERMES_TEST_PROFILE_ENV': 'work-runtime',
|
||||
'THREAD_HERMES_HOME': str(profile_home),
|
||||
'THREAD_HERMES_TEST_PROFILE_ENV': 'work-runtime',
|
||||
}
|
||||
assert captured['aux_env'] == {
|
||||
'HERMES_HOME': str(profile_home),
|
||||
'HERMES_TEST_PROFILE_ENV': 'work-runtime',
|
||||
'THREAD_HERMES_HOME': str(profile_home),
|
||||
'THREAD_HERMES_TEST_PROFILE_ENV': 'work-runtime',
|
||||
'SKILL_MODULE_HOME': profile_home,
|
||||
'SKILL_MODULE_DIR': profile_home / 'skills',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user