HERMES-GIELIE/tests/test_pr1341_context_window_persistence.py

153 lines
7.2 KiB
Python
Raw Normal View History

"""Regression test for PR #1341 + Opus pre-release review of v0.50.246.
PR #1341 added context_length/threshold_tokens/last_prompt_tokens fields to
the Session model — but didn't add the writer that actually populates them
during streaming. The pre-release review caught this: without the writer,
the user-visible bug (context-ring shows 0% after page reload) would NOT
have been fixed by #1341 alone.
This test verifies that:
1. After a streaming turn completes, the session's context_length /
threshold_tokens / last_prompt_tokens are written from the agent's
compressor BEFORE s.save() is called (so they land on disk).
2. GET /api/session response includes the populated values.
3. A reloaded session retains the populated values.
Implementation reference: api/streaming.py around line 2188 (the per-turn
post-merge save) writes from getattr(agent, 'context_compressor', None).
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
STREAMING = ROOT / "api" / "streaming.py"
MODELS = ROOT / "api" / "models.py"
ROUTES = ROOT / "api" / "routes.py"
def test_streaming_persists_context_fields_on_session_before_save():
"""The post-merge per-turn save block must write the three fields to the
session BEFORE calling s.save(), otherwise the values never reach disk."""
src = STREAMING.read_text(encoding="utf-8")
# Find the post-merge save block — anchored on the unique reasoning trace
# marker right above the persistence block.
Release v0.51.230 — Release GX (stage-p14 — extract <think> to m.reasoning #3455 + LLM Wiki last-writer #1257) (#3466) * Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257) Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 + SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes: (1) _splitThinkFromContent persist-path extraction of inline <think> blocks into m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3); (2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257). Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after preserved, unclosed blocks intact, lookalike tags not extracted). * fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review) Codex review of stage-p14 found 3 SILENT bugs, all fixed: (1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal <think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning, emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures consecutive leading blocks). +fenced-code regression test. (2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki (is_file follows symlinks), leaking external frontmatter. Now requires resolved path under wiki_root. +symlink-containment regression test. (3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter block only / capped log-heading scan), never page bodies. * fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer (_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading block. Also updated 2 tests that asserted pre-split implementation strings: test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't persist server-side) is a separate architectural decision pending Nathan. * feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1) Codex finding #1: the think-split was client-only, so the SAVED session file still carried inline <think> blocks (bloat) — the fix only compacted the browser copy. Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS helper with identical leading-only/single-block semantics, applied to the final assistant message before s.save() (extended the existing reasoning-persist block). Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content. * test: update 3 save-path source-assertion tests for #3455 server-side think-split The backend think-split (api/streaming.py reasoning-persist block) changed the literal code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it: - test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning + _split_thinking_from_content present (intent preserved: reasoning persisted before save). - test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker; bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit pre-save growth). All behavioral invariants (reasoning persisted + context fields before save) unchanged. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 05:48:35 +00:00
block_start = src.find("Persist reasoning trace in the session")
assert block_start != -1, "Reasoning-trace marker not found in streaming.py"
# Save call follows shortly after
2026-06-25 14:36:23 +00:00
save_match = re.search(r"\n[ \t]+s\.save\(\)", src[block_start:])
assert save_match is not None, "s.save() not found after the post-merge marker"
save_call = block_start + save_match.start()
Release v0.51.230 — Release GX (stage-p14 — extract <think> to m.reasoning #3455 + LLM Wiki last-writer #1257) (#3466) * Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257) Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 + SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes: (1) _splitThinkFromContent persist-path extraction of inline <think> blocks into m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3); (2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257). Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after preserved, unclosed blocks intact, lookalike tags not extracted). * fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review) Codex review of stage-p14 found 3 SILENT bugs, all fixed: (1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal <think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning, emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures consecutive leading blocks). +fenced-code regression test. (2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki (is_file follows symlinks), leaking external frontmatter. Now requires resolved path under wiki_root. +symlink-containment regression test. (3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter block only / capped log-heading scan), never page bodies. * fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer (_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading block. Also updated 2 tests that asserted pre-split implementation strings: test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't persist server-side) is a separate architectural decision pending Nathan. * feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1) Codex finding #1: the think-split was client-only, so the SAVED session file still carried inline <think> blocks (bloat) — the fix only compacted the browser copy. Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS helper with identical leading-only/single-block semantics, applied to the final assistant message before s.save() (extended the existing reasoning-persist block). Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content. * test: update 3 save-path source-assertion tests for #3455 server-side think-split The backend think-split (api/streaming.py reasoning-persist block) changed the literal code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it: - test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning + _split_thinking_from_content present (intent preserved: reasoning persisted before save). - test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker; bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit pre-save growth). All behavioral invariants (reasoning persisted + context fields before save) unchanged. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 05:48:35 +00:00
# Limit bumped to 16000 by #3455 (server-side <think> split added to the
# pre-save reasoning-persist block, + the anchor moved to the comment marker
# which sits a few lines above the former `if` anchor). The pre-save block
# legitimately grew here. NOTE: this byte-distance assertion is itself brittle
# (it must be bumped whenever a legitimate pre-save mutation block is added) — a
# structural check (presence of s.save() shortly after the post-merge marker)
# would be more durable; left as a follow-up. Earlier limits: 9000 (cancellation
# guards) → 13000 (#3263 v1) → 15000 (#3256/#3263 dual-gate).
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 07:44:03 +00:00
assert save_call - block_start < 18000, (
fix(streaming): fallback to model_metadata for context_length when compressor missing (#1318 follow-up) (#1348) * fix(streaming): fallback to model_metadata for context_length when compressor missing (#1318 follow-up) PR #1318 (shipped in v0.50.246 via PR #1341 + commit a5c10d5) persisted context_length on the session so the context-ring indicator survives page reloads. But the writer only fired when agent.context_compressor was present and reported a non-zero value. Fresh agents, interrupted streams, or compressors without the attribute would still leave s.context_length=0 — and the indicator would still show 0% on reload. This follow-up adds a fallback that calls agent.model_metadata.get_model_context_length(model, base_url) when the compressor didn't populate the value. The function returns a sensible static context window for any known model (with a 256K default for unknown models). Wrapped in a broad try/except because older hermes-agent builds may not expose the helper. Sourced from PR #1344 (@jasonjcwu) — extracted into this focused follow-up after #1344 was closed as superseded by #1341. Adds 6 structural tests covering: import + call presence, falsy-gate, agent.model/base_url passing, exception swallowing, save() ordering, result assignment. Closes the data-flow gap in #1318 for the compressor-missing case. * test: relax pr1341 block-size assertion to accommodate the new fallback --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-30 17:27:56 +00:00
"s.save() should be close to the post-merge marker — block expanded unexpectedly. "
"If you've added a new pre-save mutation block here, bump this limit."
)
block = src[block_start:save_call]
# The three fields must all be assigned on s within this block
assert "s.context_length" in block, (
"s.context_length must be written before s.save() in the post-merge block"
)
assert "s.threshold_tokens" in block, (
"s.threshold_tokens must be written before s.save() in the post-merge block"
)
assert "s.last_prompt_tokens" in block, (
"s.last_prompt_tokens must be written before s.save() in the post-merge block"
)
# The values must come from the agent's context_compressor
assert "context_compressor" in block, (
"Values must be sourced from agent.context_compressor"
)
def test_session_init_accepts_context_fields():
"""Session.__init__ must accept the three fields as named kwargs."""
src = MODELS.read_text(encoding="utf-8")
# The init signature spans many lines — read the full def block
init_match = re.search(r"def __init__\(self,(.*?)\):", src, re.DOTALL)
assert init_match, "Session.__init__ signature not found"
sig = init_match.group(1)
assert "context_length" in sig, "Session.__init__ must accept context_length"
assert "threshold_tokens" in sig, "Session.__init__ must accept threshold_tokens"
assert "last_prompt_tokens" in sig, "Session.__init__ must accept last_prompt_tokens"
def test_session_metadata_fields_includes_context_fields():
"""Session.save() METADATA_FIELDS must include all three for round-trip persistence."""
src = MODELS.read_text(encoding="utf-8")
# Locate METADATA_FIELDS list
meta_match = re.search(
r"METADATA_FIELDS\s*=\s*\[(.*?)\]",
src,
re.DOTALL,
)
assert meta_match, "METADATA_FIELDS list not found in Session.save"
fields = meta_match.group(1)
assert "'context_length'" in fields, "METADATA_FIELDS must include 'context_length'"
assert "'threshold_tokens'" in fields, "METADATA_FIELDS must include 'threshold_tokens'"
assert "'last_prompt_tokens'" in fields, "METADATA_FIELDS must include 'last_prompt_tokens'"
def test_session_compact_exposes_context_fields():
"""Session.compact() must include the three fields in its output dict."""
src = MODELS.read_text(encoding="utf-8")
# Find compact() method body
compact_idx = src.find("def compact(")
assert compact_idx != -1, "Session.compact not found"
# Look ahead for the next def or 200 lines
end = src.find("\n def ", compact_idx + 1)
body = src[compact_idx:end if end != -1 else compact_idx + 4000]
assert "'context_length':" in body, "compact() must include context_length"
assert "'threshold_tokens':" in body, "compact() must include threshold_tokens"
assert "'last_prompt_tokens':" in body, "compact() must include last_prompt_tokens"
def test_routes_session_get_returns_context_fields():
"""GET /api/session response must include the three fields."""
src = ROUTES.read_text(encoding="utf-8")
# The session-detail response builder uses getattr(s, ..., 0) or 0 pattern.
# Look for the three keys in the same response shape.
assert '"context_length"' in src, "GET /api/session response must include context_length"
assert '"threshold_tokens"' in src, "GET /api/session response must include threshold_tokens"
assert '"last_prompt_tokens"' in src, "GET /api/session response must include last_prompt_tokens"
def test_session_round_trip_persists_context_fields(tmp_path, monkeypatch):
"""Real round-trip: save a Session with the fields set, reload, fields still there.
Patches SESSION_DIR on the live api.models module so we don't pollute
sys.modules state and break test ordering for sibling tests that depend
on a stable api.models import (e.g. test_session_sidecar_repair.py).
"""
from api import models
# Use tmp_path as the session dir for this test only
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(models, "SESSION_DIR", sessions_dir)
s = models.Session(session_id="ctxtest1", title="Context test")
s.context_length = 200000
s.threshold_tokens = 180000
s.last_prompt_tokens = 45123
s.save()
# Reload from disk
s2 = models.Session.load("ctxtest1")
assert s2 is not None, "Session should reload"
assert s2.context_length == 200000, f"context_length lost on reload: got {s2.context_length}"
assert s2.threshold_tokens == 180000, f"threshold_tokens lost on reload: got {s2.threshold_tokens}"
assert s2.last_prompt_tokens == 45123, f"last_prompt_tokens lost on reload: got {s2.last_prompt_tokens}"