Files
hermes-webui/tests/test_issue798.py
T
nesquena-hermes f53556b3ff fix(cancel-stream): rename tool_calls to _partial_tool_calls (Opus MUST-FIX)
Opus pass-2 review of v0.50.251 caught a critical regression in PR
#1375:

The cancel-partial message stored captured tool calls under the
'tool_calls' key. That key is whitelisted by _API_SAFE_MSG_KEYS so
_sanitize_messages_for_api forwarded the entries to the next-turn
LLM call. But the captured entries use the WebUI internal shape
({name, args, done, duration, is_error}) — they don't have the
OpenAI/Anthropic id + function: {name, arguments} envelope. Strict
providers (OpenAI, Anthropic, Z.AI/GLM) would 400 on the malformed
entries. Net effect: the very cancel-then-continue scenario PR
#1375 aimed to improve becomes a hard fail.

Fix:
- Rename the persisted key to '_partial_tool_calls' (underscore-
  prefixed private key NOT in _API_SAFE_MSG_KEYS, so sanitize
  correctly strips it).
- Update static/messages.js hasMessageToolMetadata check to also
  recognize _partial_tool_calls for UI rendering.
- Update test_issue1361_cancel_data_loss.py assertion to check
  _partial_tool_calls (and tool_calls as legacy fallback).

Plus 2 NIT fixes from the same Opus review:

NIT 1 (api/profiles.py:153): re.match → re.fullmatch for consistency
with other _PROFILE_ID_RE callers in the codebase. The trailing-
newline footgun ($ matches before final \n in re.match) is now
closed. Without #1373's is_dir() guard, a name like 'valid\n' would
have created a directory named 'valid\n' on Linux. Doesn't escape
<HERMES_HOME>/profiles/ via Path joining, but unintended.

NIT 2 (test_issue798.py): R19j coverage gaps — added trailing-
newline tests, length-boundary tests (64-char valid, 65-char
rejected), single-char minimum, and non-ASCII / Unicode-trick tests.

New regression test (tests/test_pr1375_partial_tool_calls_sanitize.py):
- test_partial_tool_calls_field_not_forwarded_to_llm: pins that
  sanitize-for-API strips _partial_tool_calls + reasoning + does
  NOT have tool_calls on a partial message
- test_legitimate_tool_calls_are_preserved_for_completed_turns:
  pins that real OpenAI-shape tool_calls on completed turns survive
  sanitize unchanged

Tests: 3486 passing (3484 → 3486, +2 sanitize tests).
2026-04-30 23:43:23 +00:00

209 lines
9.4 KiB
Python

"""
Issue #798 — Profile isolation: switching profile in one browser client must not
affect sessions created by other concurrent clients.
Root cause: _active_profile was a process-level global in api/profiles.py.
Fix: new_session() now accepts an explicit `profile` param passed from the client
request body (S.activeProfile), which bypasses the shared global entirely.
get_hermes_home_for_profile() resolves a HERMES_HOME path from a name without
touching os.environ or module-level state.
"""
import os
import sys
import threading
from pathlib import Path
from unittest.mock import patch
import pytest
# ── R19: get_hermes_home_for_profile ─────────────────────────────────────────
def test_get_hermes_home_for_profile_returns_default_for_none():
"""R19a: None / empty string / 'default' all return the base home."""
import api.profiles as p
base = p._DEFAULT_HERMES_HOME
assert p.get_hermes_home_for_profile(None) == base
assert p.get_hermes_home_for_profile('') == base
assert p.get_hermes_home_for_profile('default') == base
def test_get_hermes_home_for_profile_returns_profile_subdir(tmp_path, monkeypatch):
"""R19b: Named profile that exists returns its subdirectory."""
import api.profiles as p
profile_dir = tmp_path / 'profiles' / 'alice'
profile_dir.mkdir(parents=True)
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
result = p.get_hermes_home_for_profile('alice')
assert result == profile_dir
def test_get_hermes_home_for_profile_returns_profile_path_for_missing_profile(tmp_path, monkeypatch):
"""R19c: Named profile that does not exist on disk now returns the
profile-scoped path (created on first use by the agent layer), NOT the
base home. Tightened in v0.50.251 / PR #1373 to fix #1195: the previous
is_dir() fallback caused new profiles to silently route every session
back to the default profile until the directory existed on disk.
Path traversal is still blocked by the _PROFILE_ID_RE regex (R19j)."""
import api.profiles as p
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
result = p.get_hermes_home_for_profile('ghost')
assert result == tmp_path / 'profiles' / 'ghost'
def test_get_hermes_home_for_profile_does_not_mutate_globals():
"""R19d: get_hermes_home_for_profile() must never change _active_profile or os.environ."""
import api.profiles as p
before_active = p._active_profile
before_hermes_home = os.environ.get('HERMES_HOME')
p.get_hermes_home_for_profile('some-other-profile')
assert p._active_profile == before_active, (
"get_hermes_home_for_profile() must not mutate _active_profile"
)
assert os.environ.get('HERMES_HOME') == before_hermes_home, (
"get_hermes_home_for_profile() must not mutate os.environ['HERMES_HOME']"
)
# ── R19e-h: new_session() profile isolation ───────────────────────────────────
# These tests call new_session() directly in-process. Session.save() would write
# to SESSION_DIR which is set from HERMES_WEBUI_STATE_DIR at import time and may
# point to a test-scoped tmp dir that has already been torn down. We patch save()
# to a no-op — the tests only care about s.profile, not persistence.
def test_new_session_uses_explicit_profile_not_global():
"""R19e: new_session(profile='alice') stamps session.profile='alice' even when
the process-level _active_profile is 'default'.
Core fix for #798: client B's session is tagged to B's profile, not the global.
"""
import api.profiles as p
import api.models as m
original = p._active_profile
try:
p._active_profile = 'default'
with patch.object(m.Session, 'save', return_value=None):
s = m.new_session(profile='alice')
assert s.profile == 'alice', (
f"Expected s.profile='alice', got {s.profile!r}. "
"new_session() should use the explicit profile param, not the global."
)
finally:
p._active_profile = original
def test_new_session_falls_back_to_global_when_profile_not_supplied():
"""R19f: new_session() without explicit profile still reads _active_profile (backward compat)."""
import api.profiles as p
import api.models as m
original = p._active_profile
try:
p._active_profile = 'default'
with patch.object(m.Session, 'save', return_value=None):
s = m.new_session()
assert s.profile == 'default'
finally:
p._active_profile = original
def test_new_session_none_profile_falls_back_to_global():
"""R19g: profile=None explicitly also falls back to the global (same as omitting it)."""
import api.profiles as p
import api.models as m
original = p._active_profile
try:
p._active_profile = 'default'
with patch.object(m.Session, 'save', return_value=None):
s = m.new_session(profile=None)
assert s.profile == 'default'
finally:
p._active_profile = original
def test_concurrent_new_sessions_get_correct_profiles():
"""R19h: Two threads call new_session() with different explicit profiles simultaneously.
Each session must be stamped with its own profile, never the other's.
Direct reproduction of the #798 race (minus the actual switch_profile() call).
"""
import api.models as m
results = {}
errors = []
# Patch Session.save ONCE around both threads — not once per thread.
# Per-thread `with patch.object(...)` nested across threads has a known
# concurrency bug in unittest.mock where one thread's __exit__ can capture
# the other thread's mock as the "original" and leave the class attribute
# permanently pointing at a MagicMock, breaking every later test that
# calls Session.save (any test writing a real session file).
def make_session(profile_name, key):
try:
s = m.new_session(profile=profile_name)
results[key] = s.profile
except Exception as exc:
errors.append(exc)
with patch.object(m.Session, 'save', return_value=None):
t1 = threading.Thread(target=make_session, args=('alice', 'alice'))
t2 = threading.Thread(target=make_session, args=('bob', 'bob'))
t1.start(); t2.start()
t1.join(timeout=5); t2.join(timeout=5)
assert not errors, f"Threads raised: {errors}"
assert results.get('alice') == 'alice', f"alice session had profile {results.get('alice')!r}"
assert results.get('bob') == 'bob', f"bob session had profile {results.get('bob')!r}"
# ── R19i: sessions.js sends profile in the POST body ─────────────────────────
def test_sessions_js_sends_profile_in_new_session_post():
"""R19i: sessions.js newSession() must include profile:S.activeProfile in the
JSON body sent to /api/session/new — the client-side half of the #798 fix."""
js = (Path(__file__).parent.parent / 'static' / 'sessions.js').read_text()
assert 'profile:S.activeProfile' in js or 'profile: S.activeProfile' in js, (
"sessions.js newSession() must send profile: S.activeProfile in the POST body "
"so the server uses the tab's active profile, not the process global."
)
def test_get_hermes_home_for_profile_rejects_path_traversal():
"""R19j: get_hermes_home_for_profile() must reject names that don't match
_PROFILE_ID_RE (e.g. path traversal like '../../etc') and return the base
home. After v0.50.251 / PR #1373 removed the is_dir() fallback, the regex
is the SOLE guard against path traversal — verify each known-bad shape
still returns the base home, not a traversed path."""
import api.profiles as p
base = p._DEFAULT_HERMES_HOME
assert p.get_hermes_home_for_profile('../../etc') == base
assert p.get_hermes_home_for_profile('../escape') == base
assert p.get_hermes_home_for_profile('/absolute/path') == base
assert p.get_hermes_home_for_profile('has spaces') == base
assert p.get_hermes_home_for_profile('UPPERCASE') == base
# Valid names now route to the profile-scoped path (created on first use).
# Previously these returned `base` because no profile dir existed on disk.
assert p.get_hermes_home_for_profile('alice') == base / 'profiles' / 'alice'
assert p.get_hermes_home_for_profile('my-profile') == base / 'profiles' / 'my-profile'
assert p.get_hermes_home_for_profile('profile_1') == base / 'profiles' / 'profile_1'
# R19j coverage gaps closed in v0.50.251 per Opus pre-release review:
# - Trailing-newline names must be rejected (re.match would let them through;
# re.fullmatch correctly anchors $). Catches the match-vs-fullmatch footgun.
assert p.get_hermes_home_for_profile('valid\n') == base
assert p.get_hermes_home_for_profile('a\n') == base
# - Length boundaries: 64 chars (max valid: 1 + 63 suffix) routes to profile path,
# 65 chars rejected.
assert p.get_hermes_home_for_profile('a' * 64) == base / 'profiles' / ('a' * 64)
assert p.get_hermes_home_for_profile('a' * 65) == base
# - Single-char name is the minimum valid form.
assert p.get_hermes_home_for_profile('a') == base / 'profiles' / 'a'
# - Non-ASCII / Unicode-trick names are rejected by the ASCII-only charset.
assert p.get_hermes_home_for_profile('voilà') == base
assert p.get_hermes_home_for_profile('名前') == base