Skip to content

Commit 1bbbc43

Browse files
nesquena-hermesGeoffBao
authored andcommitted
release: add nesquena#1373 + nesquena#1375; fix R19c/R19j contracts for nesquena#1373 behavior change
Adds two more contributor PRs to the v0.50.251 batch per user directive (per-PR review + Opus review for nesquena#1373; nesquena#1375 was clean ship-on-sight). nesquena#1375 (@bergeouss, +382 LOC, all CI green) — fixes nesquena#1361 paid-token data loss on Stop/Cancel. Mirrors the existing STREAM_PARTIAL_TEXT pattern from nesquena#893: adds STREAM_REASONING_TEXT and STREAM_LIVE_TOOL_CALLS shared dicts populated during streaming and read by cancel_stream(). Also fixes the §C reasoning-only-creates-no-message gap where the strip-thinking-blocks regex returned empty string and the if-guard skipped the partial append. 8 regression tests covering all 3 sections plus tools+text combinations. nesquena#1373 (@bergeouss, +105 LOC, had CI failures pre-fix) — fixes nesquena#1195 new-profile-routes-to-default. The is_dir() guard in get_hermes_home_for_profile() caused new profiles (no session yet) to silently route every session back to the default profile until the directory existed on disk. Removed the guard; profile path is now returned unconditionally. Pre-release fix for nesquena#1373's CI failures: the change flipped two behaviors pinned by tests in nesquena#798: - R19c (test_get_hermes_home_for_profile_falls_back_for_missing_profile) asserted nonexistent → base. Renamed and updated to assert the new always-return-profile-path behavior. - R19j (test_get_hermes_home_for_profile_rejects_path_traversal) asserted that valid-but-nonexistent profile names → base. Updated to assert profile-scoped path. Also updated docstring: the _PROFILE_ID_RE regex is now the SOLE defense against path traversal (previously is_dir() was a defense-in-depth layer); verified each known-bad shape still returns base. Tests: 3484 passing (3471 → 3484, +13).
1 parent 033bbe2 commit 1bbbc43

2 files changed

Lines changed: 19 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
### Fixed
88
- **Sidebar lineage collapse now works for WebUI JSON sessions, not just imported gateway rows** — PR #1358 (v0.50.249) added the client-side lineage-collapse helper but `/api/sessions` only included `_lineage_root_id` for gateway-imported rows. WebUI JSON sessions (the common case) had no grouping key, so cross-surface continuation chains (CLI-close → WebUI continuation, or compression chains within WebUI) still rendered as separate sidebar rows. Now `/api/sessions` reads `parent_session_id` and `end_reason` from `state.db.sessions` for every WebUI session id in the sidebar payload, walks the parent chain when `end_reason in {'compression', 'cli_close'}`, and exposes `_lineage_root_id` + `_compression_segment_count`. Cycle-detected via a `seen` set; depth-bounded to 20 hops to cap pathological data. **Pre-release fix:** swapped the original full-table-scan for a parameterized `WHERE id IN (...)` query that hits PRIMARY KEY + `idx_sessions_parent` — ~50× faster at 1000 rows, scales linearly. **Pre-release fix:** chunked IN clause to 500 vars to stay under SQLITE_MAX_VARIABLE_NUMBER on older sqlite (Python 3.9 ships sqlite 3.31 with default limit 999) — without this a power user with 2000+ sessions in the sidebar would hit `OperationalError: too many SQL variables`, the silent except-wrapper would swallow it, and lineage collapse would never work for them. **Pre-release fix:** tightened `parent_session_id` exposure — only emitted when the parent's `end_reason` is `compression` or `cli_close` (not for `user_stop`/etc), since the frontend's `_sessionLineageKey` falls through to `parent_session_id` and would incorrectly collapse two children of a non-continuation parent into a single row. (`api/agent_sessions.py`, `api/models.py`, `tests/test_session_lineage_metadata_api.py`, `tests/test_pr1370_lineage_metadata_perf_and_orphan.py`, `tests/test_gateway_sync.py`) @dso2ng — PR #1370
99
- **Manual cron runs persist output and metadata like scheduled runs** — manual WebUI cron runs called `cron.scheduler.run_job(job)` and then only cleared the in-memory running flag. The job's output was dropped (never written via `save_job_output`) and `last_run_at` / `last_status` were never updated. Now the manual-run wrapper (`_run_cron_tracked`) matches the scheduled-cron path at `cron/scheduler.py:1334-1364` exactly: saves output, marks the job complete, treats empty `final_response` as a soft failure (with the same error string), and records failures via `mark_job_run(False, str(e))`. (`api/routes.py`, `tests/test_cron_manual_run_persistence.py`) @NocGeek — PR #1372 (split out from the held #1352 per pre-release feedback)
10+
- **Reasoning trace, tool calls, and partial output preserved on Stop/Cancel** — three distinct data-loss paths fixed: §A reasoning text accumulated in a thread-local `_reasoning_text` was invisible to `cancel_stream()` because it went out of scope when the thread was interrupted; §B live tool calls in thread-local `_live_tool_calls` were similarly lost; §C reasoning-only streams (no visible tokens) produced no partial assistant message because the thinking-block regex strip returned empty string and the `if _stripped:` guard skipped the append. The fix mirrors the existing `STREAM_PARTIAL_TEXT` pattern (#893) by adding two new shared dicts (`STREAM_REASONING_TEXT`, `STREAM_LIVE_TOOL_CALLS`) populated during streaming and read by `cancel_stream()`. The cancel path now appends the partial assistant message when content text, reasoning trace, OR tool calls exist (not just text). Eliminates "paid tokens disappeared" reports on Stop. 8 regression tests covering all three sections plus tools+text combinations. (`api/config.py`, `api/streaming.py`, `tests/test_issue1361_cancel_data_loss.py`) @bergeouss — PR #1375, fixes #1361
11+
- **New profiles route sessions to the profile dir on first use, not back to default** — `get_hermes_home_for_profile()` had a `if profile_dir.is_dir(): return profile_dir; return _DEFAULT_HERMES_HOME` fallback. New profiles (no session yet, so no dir) routed every session back to default until the directory existed on disk — making profile switching silently broken for the first session of every new profile. Removed the `is_dir()` guard; the profile path is now returned unconditionally and the directory is created on first use by the agent/session layer. Path traversal is still blocked by the `_PROFILE_ID_RE` regex (`^[a-z0-9][a-z0-9_-]{0,63}$`); R19j tests were updated to pin that the regex is now the sole defense. R19c was tightened to assert the new behavior. 5 regression tests in `test_issue1195_session_profile_routing.py` covering existing-profile, non-existent-profile (the core fix), None, empty-string, and 'default' return paths. (`api/profiles.py`, `tests/test_issue798.py`, `tests/test_issue1195_session_profile_routing.py`) @bergeouss — PR #1373, fixes #1195
1012

1113

1214
## [v0.50.250] — 2026-04-30

tests/test_issue798.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,18 @@ def test_get_hermes_home_for_profile_returns_profile_subdir(tmp_path, monkeypatc
4040
assert result == profile_dir
4141

4242

43-
def test_get_hermes_home_for_profile_falls_back_for_missing_profile(tmp_path, monkeypatch):
44-
"""R19c: Named profile that does not exist falls back to base home."""
43+
def test_get_hermes_home_for_profile_returns_profile_path_for_missing_profile(tmp_path, monkeypatch):
44+
"""R19c: Named profile that does not exist on disk now returns the
45+
profile-scoped path (created on first use by the agent layer), NOT the
46+
base home. Tightened in v0.50.251 / PR #1373 to fix #1195: the previous
47+
is_dir() fallback caused new profiles to silently route every session
48+
back to the default profile until the directory existed on disk.
49+
Path traversal is still blocked by the _PROFILE_ID_RE regex (R19j)."""
4550
import api.profiles as p
4651

4752
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
4853
result = p.get_hermes_home_for_profile('ghost')
49-
assert result == tmp_path
54+
assert result == tmp_path / 'profiles' / 'ghost'
5055

5156

5257
def test_get_hermes_home_for_profile_does_not_mutate_globals():
@@ -171,16 +176,19 @@ def test_sessions_js_sends_profile_in_new_session_post():
171176

172177
def test_get_hermes_home_for_profile_rejects_path_traversal():
173178
"""R19j: get_hermes_home_for_profile() must reject names that don't match
174-
_PROFILE_ID_RE (e.g. path traversal like '../../etc') and return the base home.
175-
The regex guard is defence-in-depth on top of the is_dir() fallback."""
179+
_PROFILE_ID_RE (e.g. path traversal like '../../etc') and return the base
180+
home. After v0.50.251 / PR #1373 removed the is_dir() fallback, the regex
181+
is the SOLE guard against path traversal — verify each known-bad shape
182+
still returns the base home, not a traversed path."""
176183
import api.profiles as p
177184
base = p._DEFAULT_HERMES_HOME
178185
assert p.get_hermes_home_for_profile('../../etc') == base
179186
assert p.get_hermes_home_for_profile('../escape') == base
180187
assert p.get_hermes_home_for_profile('/absolute/path') == base
181188
assert p.get_hermes_home_for_profile('has spaces') == base
182189
assert p.get_hermes_home_for_profile('UPPERCASE') == base
183-
# Valid names still work
184-
assert p.get_hermes_home_for_profile('alice') == base # nonexistent → fallback
185-
assert p.get_hermes_home_for_profile('my-profile') == base
186-
assert p.get_hermes_home_for_profile('profile_1') == base
190+
# Valid names now route to the profile-scoped path (created on first use).
191+
# Previously these returned `base` because no profile dir existed on disk.
192+
assert p.get_hermes_home_for_profile('alice') == base / 'profiles' / 'alice'
193+
assert p.get_hermes_home_for_profile('my-profile') == base / 'profiles' / 'my-profile'
194+
assert p.get_hermes_home_for_profile('profile_1') == base / 'profiles' / 'profile_1'

0 commit comments

Comments
 (0)