Commit Graph

68 Commits

Author SHA1 Message Date
Rod Boev e77ca8dbac fix(#2698): keep the pinned home across isolated fallbacks 2026-06-20 00:48:20 +00:00
Rod Boev c7375c60c4 fix(#2698): clamp isolated profile lookups to the pinned home 2026-06-20 00:48:20 +00:00
Rod Boev 41f2015a32 fix(#2698): close isolated profile escape hatches 2026-06-20 00:48:20 +00:00
Rod Boev f086158c10 fix(#2698): keep isolated deployments on their own state and profile roots 2026-06-20 00:48:19 +00:00
Rod Boev c00bfab9f5 fix(#2698): keep isolated default-named profiles pinned 2026-06-20 00:48:19 +00:00
Rod Boev 498a5e73e3 fix(#2698): pin isolated runtime home to configured profile 2026-06-20 00:48:19 +00:00
Rod Boev 8da03dbbad Use HERMES_HOME path directly for isolated profile home resolution 2026-06-20 00:48:19 +00:00
Rod Boev 0291af5f85 Derive isolated profile name from HERMES_HOME path, not process-level default 2026-06-20 00:48:19 +00:00
Rod Boev a2ca2ff0c2 fix(profiles): snapshot HERMES_HOME at import time for isolation detection (#2698) 2026-06-20 00:48:19 +00:00
Rod Boev 35eced6f35 fix(profiles): broaden except clause and sanitize error in isolated mode (#2698) 2026-06-20 00:48:19 +00:00
Rod Boev 36dbe1a415 fix(profiles): return 403 for isolated-mode guards, fix fallback identity (#2698) 2026-06-20 00:48:19 +00:00
Rod Boev 963f7c3864 fix(profiles): honor isolated HERMES_HOME profile mode (#2698) 2026-06-20 00:48:19 +00:00
James dfcc06acdb fix: align webui runtime env handling with gateway behavior 2026-06-18 19:01:17 +00:00
Hermes Agent b5d382a3bb fix(profiles): scope the detached models-rebuild worker to the active profile (#3957)
Codex + Opus both independently caught a CORE gap in the first cut: the
bounded /api/models rebuild runs on a detached 'models-catalog-rebuild' daemon
thread that inherits neither the request-profile thread-local (#798) nor
os.environ. So on a non-default profile the worker probed the DEFAULT profile's
credentials and, when the 4s budget was exceeded, published the rebuilt catalog
to the DEFAULT profile's disk cache (cross-contamination) — exactly the slow
path a non-default cold rebuild takes.

Fix:
- profile_scope_for_detached_worker(profile_name): sets the request-profile TLS
  AND applies the profile .env on the worker thread, restoring both on exit
  (no-op for default). Distinct from profile_env_for_active_request (which reads
  the current thread's TLS and must not clear it).
- get_available_models() captures the active profile on the request thread and
  wraps the rebuild worker body (probe + over-budget publish + disk save +
  fingerprint) in that scope; the legacy synchronous rebuild applies the profile
  env on the foreground. /api/models route no longer wraps (the work moved into
  get_available_models so ALL callers — chat/start, resolution — are fixed).
- 2 new regression tests incl. the worker-thread before/inside/after assertion.

Empirically verified: a fresh worker thread resolved models_cache.json/default
WITHOUT the scope (the bug) and models_cache.work.json + the work .env + the
work auth.json WITH it.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-11 01:41:24 +00:00
Hermes Agent 70d391c44a fix(profiles): scope /api/providers + /api/models to the active profile (#3957)
On a non-default profile, Settings → Providers timed out and the model
picker showed only the default profile's models. WebUI profile switching is
per-client/cookie-scoped (#798), but two read-only paths resolved from the
process-global default profile:

- Facet A: /api/providers + /api/models did not apply the active profile's
  .env around the read, so get_auth_status() / provider_model_ids() / custom
  key lookups resolved the default profile's credentials. On a non-default
  profile the auth probes could stall past the 30s frontend abort.
- Facet B: the /api/models disk cache was a single import-time
  STATE_DIR/models_cache.json shared across every profile, while the cache
  fingerprint is profile-specific -> a non-default profile rejected the shared
  snapshot every read and cold-rebuilt (the slow serial-probe path).

Fix:
- api.profiles.profile_env_for_active_request(): applies the active
  per-request profile's .env for the duration of the read (delegates to the
  existing profile_env_for_background_worker used by streaming). No-op for the
  default/root profile, so single-profile deployments are byte-identical.
- api.config._get_models_cache_path(): profile-keys the disk cache filename
  (models_cache.<profile>.json) derived from the default path; default profile
  keeps models_cache.json unchanged (no file migration).
- routes.py: wrap both GET handlers in profile_env_for_active_request.
- conftest: restore api.profiles._active_profile + clear request-profile TLS
  after each test (a pre-existing isolation hole that profile-keyed cache
  paths newly surface under sharding).

Tested: 10 new regression tests; live before/after on isolated servers shows
a non-default profile now surfaces its configured provider (deepseek) with
its own credentials + its own cache file, where master showed only the
default profile's providers and one shared cache.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-11 01:17:22 +00:00
nesquena-hermes 1bc190fb61 Release v0.51.313 — Release KC (instant profile switcher — skip per-profile alias scan) (#3779)
list_profiles_api() builds rows from cheap upstream helpers and skips find_alias_for_profile (which reads ~2.4GB of binaries from ~/.local/bin per dropdown open), with a 4s TTL cache + graceful fallback. 4700ms->250ms cold, 0ms cached. Full suite 8183 passed, Codex SAFE, Opus SHIP-IT. Supersedes #3772.
2026-06-07 09:27:32 -07:00
nesquena-hermes 0b223e91bc Release v0.51.287 — Release JC (stage-r22 — WeCom session classification #3653 + worker-profile picker hiding #3662) (#3695)
* feat(sessions): classify WeCom gateway sessions as messaging (#3653)

Co-authored-by: Frank Song <franksong2702@gmail.com>

* feat(profiles): hide worker profiles from chat picker (#3662)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.287 — Release JC (stage-r22)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-05 18:56:43 -07:00
nesquena-hermes 9b933e2c83 Release v0.51.266 — Release IH (stage-r16) (#3641)
## Release v0.51.266 — Release IH (stage-r16)

One agent-authored APPROVED fix + two un-held streaming/SSE fixes.

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3635 (#3637) | @nesquena-hermes (nesquena APPROVED) | Composer profile chip reads `S.activeProfile` again — a #3331 regression keyed it on the loaded session's profile, so opening a cross-profile session made the chip disagree with the dropdown checkmark and misrepresent where the next message routes. #3331's project/session-op scoping is unaffected. |
| #3587 (#3605) | @rodboev | Reasoning persists to the correct intermediate assistant message in multi-turn tool flows. The index only advanced in `on_interim_assistant` (suppressed for contentless tool-call messages) → post-tool reasoning was mis-attributed; it now also advances at the `on_tool` boundary, guarded against over-increment. **(un-held — finding resolved)** |
| #2660 (#3558) | @franksong2702 | Session-event SSE no longer wakes every tab across profiles and never drops a relevant refresh — profile attached when known, root/`default` aliases stay unscoped, and the `maxsize=1` queue falls back to unscoped refresh-all on a profile-mismatch coalesce. **(un-held — both findings resolved)** |

### Gate
- Full pytest suite: **7770 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — chip matches dropdown/routing (no #3331 scoping regression), reasoning-index advance composes with the agent's tool/interim callback ordering, session-events coalesce safely with no dropped refresh and no profile data leak (`/api/sessions` still server-side filtered).

Co-authored-by: nesquena <nesquena@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-05 00:44:03 -07:00
nesquena-hermes 76da1d144a fix(profiles): TOCTOU-safe _SKILLS_STATS_CACHE read via .get() (Opus pre-release nit on #3341) 2026-06-01 18:38:39 +00:00
nesquena-hermes de22c607bc stage-batch10: #3327 model-id normalize + #3334 RFC slice doc + #3341 profile skill counts
#3327 fix(reasoning): normalize custom-provider model ids for fallback heuristics
Co-authored-by: Carry00 <Carry00@users.noreply.github.com>

#3334 docs(rfc): mark run-adapter Slice 4f shipped, define Slice 4g gate
Co-authored-by: Michaelyklam <Michaelyklam@users.noreply.github.com>

#3341 fix(profiles): show enabled vs compatible skill counts
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-01 18:24:05 +00:00
AJV20 d8471a7d87 fix(profiles): avoid config import cycle 2026-06-01 05:05:16 +00:00
nesquena-hermes 5742a23836 fix: apply Opus+Codex gate findings — stale-stopped not configured (#1944), narrow #2905 markers, scope except
Both pre-release reviewers (Opus advisor + Codex regression gate) converged on
the same MUST-FIX:

- #3194: treating gateway_stale_stopped_state as 'configured' contradicted
  #1944 (a stopped root gateway should read like 'not configured' so the
  banner doesn't nag). Now ONLY stale-RUNNING metadata
  (reason=gateway_stale_running_state or gateway_state=='running') flips
  configured=True; stale-stopped falls through to bool(identity_map) like the
  genuinely-unconfigured case. Updated the test accordingly + added a
  stale-stopped no-regression test.

Opus follow-ups also applied:
- #2905: narrowed the populated-home markers to WebUI-only artifacts
  (webui/, webui/sessions, webui/settings.json), dropping config.yaml/auth.json
  so a long-time agent user installing WebUI fresh isn't wrongly diverted to
  the legacy %USERPROFILE%\.hermes (auth.json predates #2897 there).
- profiles._resolve_base_hermes_home(): narrowed except Exception -> ImportError
  so a real bug in the config helper still surfaces.

Adjacent suites green: #2840, #1879, gateway_status_agent_health (66 tests).
2026-05-31 19:12:30 +00:00
nesquena-hermes 5dc11ef977 fix: Windows upgrade state stranding (#2905) + gateway-configured banner on first deploy (#3194)
Two urgent breaking bugs that had no PR, combined into one hotfix.

#2905 (data-loss-class): v0.51.134 moved the Windows default Hermes home
from %USERPROFILE%\.hermes to %LOCALAPPDATA%\hermes (PR #2897) with no
migration, so upgrading Windows users opened the app to empty
sessions/pins/settings (data intact on disk, at an address the new build
no longer read). _platform_default_hermes_home() now prefers the populated
legacy home ONLY when the new location is not yet established —
non-destructive (no file moves) and self-healing on next launch.
profiles._resolve_base_hermes_home() delegates to the same config helper so
the active-profile pointer can never drift from STATE_DIR.

#3194: GET /api/gateway/status reported 'Gateway not configured' on a fresh
two-container Docker deploy because an alive=None + gateway_stale_running_state
health payload with an empty identity_map fell through to
configured=bool(identity_map)=False. The alive=None branch now treats a
payload carrying gateway metadata (gateway_state detail, or a stale-running/
stale-stopped reason) as configured.

Tests: +17 regression tests (11 for #2905 incl. full truth table + non-destructive
guard + POSIX no-op; 6 for #3194 incl. 5 no-regression guards). Full suite
7090 passed, 0 failed.

Closes #2905
Closes #3194
2026-05-31 19:12:30 +00:00
gavinssr 9e5403994c fix(profiles): write API key to .env instead of config.yaml on profile creation
When a user creates a profile through the WebUI and supplies an API key,
the key was written to config.yaml under model.api_key. However, Hermes
Agent's provider layer reads keys from environment variables (.env), not
from config.yaml — making the key invisible to the actual LLM provider.

Additionally, hermes profile show reports .env: not configured when no
.env file exists, regardless of config.yaml contents, giving users the
false impression that their API key was not saved.

Changes:
- Add _PROVIDER_ENV_MAP to resolve provider IDs to .env variable names
  (kimi-coding → KIMI_API_KEY, deepseek → DEEPSEEK_API_KEY, etc.)
- Add _write_api_key_to_dotenv() that writes the key to the profile's
  .env file under the correct provider-specific variable
- Add _upsert_dotenv_line() helper for idempotent KEY=value writes
- Remove api_key writing from _write_endpoint_to_config()
- Wire _write_api_key_to_dotenv() into create_profile_api()

Fixes: profile created via WebUI shows .env: not configured despite
        correct API key being entered in the form.
2026-05-28 15:45:17 +08:00
Harlan Zhou f8a7726e09 fix(windows): align WebUI defaults with Hermes Agent home path 2026-05-25 01:58:32 +00:00
nesquena-hermes feb35893b9 Stage 393: PR #2637
# Conflicts:
#	static/sessions.js
2026-05-20 22:24:40 +00:00
dobby-d-elf fd7212b014 Optimize profile switching and session list loading 2026-05-20 08:47:49 -06:00
dobby-d-elf 5e378d3b38 sync session list from server events 2026-05-20 08:18:56 -06:00
Michael Lam 5bd1f1441d fix: route background worker credentials through profile env 2026-05-16 01:02:19 -07:00
Michael Lam 9894157120 fix: hybridize background profile env routing 2026-05-15 23:19:37 -07:00
Hermes Agent 74ee110b2f Merge pull request #2315 into stage-361
Seed bundled skills for WebUI profile creation (Michaelyklam, closes #2305)

# Conflicts:
#	CHANGELOG.md
2026-05-15 19:17:04 +00:00
Hermes Agent fb0e664a10 stage-360 maintainer fix: narrow _ENV_LOCK to env mutation only in profile_env_for_background_worker
#2299 introduced profile_env_for_background_worker() in api/profiles.py and
changed _ENV_LOCK from threading.Lock() to threading.RLock(). Both changes
were incorrect:

1. RLock masked rather than fixed the underlying deadlock. The QA
   test_env_lock_is_non_reentrant test exists precisely to enforce
   non-reentrance — RLock would let a single thread hold _ENV_LOCK across
   nested critical sections, which hides bugs while still allowing
   different-thread races.

2. The original context manager held _ENV_LOCK for the ENTIRE 'yield'
   duration, meaning the lock was held for the full background worker's
   runtime (title generation, compression, update summary — possibly
   many seconds). That blocked ALL other sessions on _ENV_LOCK, which
   the QA test_third_message_completes runtime test caught as a timeout
   on the third sequential message.

Fix: mirror the narrow-lock pattern from _run_agent_streaming:
  - Acquire _ENV_LOCK only for env mutation (set runtime_env + patch
    skill modules)
  - Release immediately, yield to worker (no lock held)
  - Reacquire in finally to restore env + skill modules

Restored _ENV_LOCK back to threading.Lock(). All 20 QA tests now pass,
including test_third_message_completes (was timing out, now 35s).
2026-05-15 17:11:45 +00:00
Michael Lam b2cbf7bb2f Seed bundled skills for WebUI profile creation 2026-05-15 09:29:46 -07:00
Hermes Agent 3b05929f1a Merge pull request #2299 into stage-360
Fix profile-scoped auxiliary routing for background workers (starship-s)
2026-05-15 16:15:39 +00:00
Frank Song 1fb047b51a Validate profile model selections 2026-05-15 18:36:38 +08:00
starship-s 4ffecdd7c9 refactor(profiles): consolidate background profile env 2026-05-15 03:58:40 -06:00
starship-s aa1c7c24f4 fix(profiles): route background aux workers via session profile 2026-05-15 03:02:42 -06:00
Frank Song 8b30ade923 Add profile creation model picker 2026-05-14 12:13:49 +08:00
Michael Lam 265496782a docs: clarify compression anchor helpers 2026-05-12 01:43:16 -07:00
Frank Song a0e9c06102 Fix HERMES_HOME skill cache patching 2026-05-11 19:12:02 +08:00
Samuel Gudi c613cfa9a7 refactor(profiles): relocate _profiles_match to api/profiles.py (#1895 review)
Maintainer review on PR #1895 flagged that mcp_server.py duplicated the
visibility model from api/routes.py:75. Move the canonical helper into
api/profiles.py (next to _is_root_profile, on which it depends) so both
api/routes.py and mcp_server.py import the same function instead of
carrying parallel definitions that could drift as the model evolves.

- api/profiles.py: + _profiles_match (verbatim from former routes.py:75-97)
- api/routes.py:   replace local definition with re-export to keep all
                   existing _profiles_match(...) call sites resolving
                   without per-call-site refactors
- mcp_server.py:   drop local copy, import _profiles_match alongside the
                   existing api.profiles imports (line 59)
- tests:           + test_profiles_match_single_source_of_truth asserts
                   identity (mcp.module._profiles_match is api.profiles._profiles_match
                   is api.routes._profiles_match) so any re-introduction of
                   a local copy trips the test
                   + test_profiles_match_input_matrix parametrize across
                   the (None|''|'default'|'foo') x (None|''|'default'|'foo'|'bar')
                   visibility matrix per maintainer suggestion

Behaviour unchanged. Zero call-site changes anywhere in api/routes.py.

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-08 17:12:01 +00:00
Michael Lam fdd6b83acb fix: allow profile switching during active streams 2026-05-06 16:11:46 +00:00
Michael Lam 8d77e0be49 fix: isolate in-process cron scheduler profiles 2026-05-06 08:47:16 -07:00
Michael Lam f6a532d7f0 fix: normalize named profile base homes 2026-05-05 00:00:29 -07:00
Michael Lam 3f3092a84e feat: add scheduled job profile selector 2026-05-05 01:18:34 +00:00
nesquena-hermes 6bc0f9c4d5 Apply Opus pre-release SHOULD-FIX + NITs (in-PR per release policy)
SHOULD-FIX #1 (renamed-root client cross-alias): drop strict-equality client
filter at static/sessions.js:1853. Server-side _profiles_match cross-aliases
'default'-tagged rows to a renamed root 'kinni'; the strict-equality client
would reject them, dropping every legacy session for renamed-root users. The
server is now solely authoritative for profile scoping.

SHOULD-FIX #2 (messaging-source dedupe ordering): _keep_latest_messaging_session_per_source
now runs AFTER the profile filter at api/routes.py:2078. Before, it ran on
the merged-cross-profile list with profile-blind keys, discarding the older
profile's row across profiles before the scope filter — leaving zero rows for
any messaging identity the active profile shared with another profile.

NIT #3: _projects_migrated flag now set only AFTER successful save_projects.
NIT #4: cleaned dead test code in test_is_root_profile_invalidation_drops_stale.
NIT #5: _create_profile_fallback's clone_from=='default' literal now routes
through _is_root_profile() for parity with the 5 other callsites.

+2 regression tests pin the SHOULD-FIX shapes:
- test_keep_latest_messaging_runs_after_profile_filter (source-string ordering)
- test_static_sessions_js_trusts_server_profile_scoping (no client re-filter)

4173 -> 4175 tests pass. 0 regressions.
2026-05-04 16:17:26 +00:00
nesquena-hermes e8862632ed fix(profiles): scope sessions, projects, and root-profile resolution to active profile (#1611, #1612, #1614)
Closes #1611 — /api/sessions filters by active profile by default; ?all_profiles=1
opt-in for aggregate views; new _profiles_match() helper honours renamed-root
cross-aliasing; static/sessions.js drops the s.is_cli_session bypass; toggle-on
re-fetches with all_profiles=1 instead of slicing client-cached rows.

Closes #1612 — new _is_root_profile() central helper consults list_profiles_api()
for is_default=True matches alongside the legacy 'default' alias. Replaces five
literal-default callsites in api/profiles.py. Memoized with explicit invalidation
hooks at create + delete. Sticky active_profile file write now stores '' for
renamed root, consistent with the legacy empty==root contract.

Closes #1614 — projects carry a profile field stamped at create-time;
/api/projects filters by active profile; /api/projects/{create,rename,delete}
and /api/session/move reject ops on cross-profile projects with 404; new
_PROJECTS_MIGRATION migration in load_projects() back-tags untagged projects
from any session that uses them, fall back to 'default'; ensure_cron_project
keys lookup by (name, profile) so each profile gets its own Cron Jobs project.

31 regression tests (9+11+11) pin the renamed-root resolution, server-side
profile scoping shape, helper invariants, cross-alias matching, migration
behavior, and active-profile guards on every project mutation endpoint.
4148 tests pass.

Reporter: @stefanpieter

Co-authored-by: stefanpieter <noreply@github.com>
2026-05-04 16:03:05 +00:00
nesquena-hermes df03055def Address review feedback: tighten profile-resolution error handling
Three small follow-ups from the review:

1. Remove the over-broad except Exception around get_active_hermes_home()
   in _handle_cron_run. The function is in-memory dict reads + one
   Path.is_dir() stat — if it raises from inside a request handler,
   api.profiles is in a state we shouldn't be making cron decisions in.
   A silent fallback to _profile_home=None re-introduces the exact
   bug #1573 fixes (worker thread runs unpinned against process-global
   HERMES_HOME). Better to 500 the request than risk silent cross-
   profile state corruption.

2. Add a thread-safety note on os.environ mutation in api/profiles.py
   explaining why _cron_env_lock is sufficient — CPython env-var
   assignment is GIL-protected at the bytecode level but the multi-step
   read-modify-write pattern (snapshot prev → assign new → restore on
   exit) is not atomic without explicit serialization. The lock makes
   the entire context-manager body run-to-completion serially, including
   any subprocess.Popen() calls inside run_job() that inherit the env.

3. New regression test (test_cron_run_does_not_silently_swallow_profile_resolution_errors)
   pinning the no-silent-fallback contract via source-level assertion.
   Catches future re-introduction of the over-broad except clause.

Co-authored-by: kowenhaoai <kowenhaoai@users.noreply.github.com>
2026-05-03 22:29:57 +00:00
貓鷹閣 Hermes 2a8311a788 fix(cron): scheduled jobs panel respects active profile
Wrap all /api/crons* endpoints in cron_profile_context so the TLS-active
profile's jobs.json is read/written, not the process-default one.

Before: cron.jobs._get_jobs_file() reads HERMES_HOME from os.environ
(process-global) at call time, bypassing WebUI's per-request thread-local
profile. Result: the Scheduled jobs panel always showed the default
profile's jobs regardless of which profile the user selected via cookie,
and CRUD operations silently wrote to the wrong jobs.json.

Fix:
- api/profiles.py: new cron_profile_context (HTTP/TLS) and
  cron_profile_context_for_home (worker threads) context managers. Both
  hold a module-level lock, swap os.environ['HERMES_HOME'], and re-patch
  cron.jobs module-level constants (HERMES_DIR/CRON_DIR/JOBS_FILE/
  OUTPUT_DIR are import-time snapshots that don't participate in the
  module's lazy __getattr__ path).
- api/routes.py: wrap all 12 cron endpoints (GET + POST). For
  /api/crons/run, capture the TLS-active home at dispatch time and
  pass it into the background thread so cron output lands in the right
  profile directory.

Tests: 3 new regression tests in test_scheduled_jobs_profile_isolation.py
cover TLS-based pinning, explicit-home pinning, and serialization of
concurrent contexts. Full cron + profile test suite (24 tests) passes.

Refs: ~/.hermes/patches/hermes-webui_scheduled-jobs-profile-isolation.patch
Obsidian: Hermes_Patches/20260504_Hermes_WebUI_Scheduled_Jobs_Profile_Isolation.md
2026-05-04 06:00:17 +08:00
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