From 7a5fc1b2a9e57977e56ec040698ceaadd160171a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:56:22 -0700 Subject: [PATCH] fix: remove automatic session JSON snapshots --- CONTRIBUTING.es.md | 2 +- CONTRIBUTING.md | 4 +- agent/agent_init.py | 7 - agent/background_review.py | 2 +- agent/session_persistence.py | 89 +---------- evals/session_snapshot_removal.py | 36 +++++ hermes_cli/config_defaults.py | 5 +- tests/agent/test_session_snapshot_removal.py | 42 ++++++ tests/agent/test_verification_stop_caching.py | 34 +---- tests/cli/test_cli_interrupt_ack_race.py | 2 - .../cli/test_cli_shutdown_memory_messages.py | 3 +- tests/run_agent/test_run_agent.py | 138 +----------------- .../test_verification_continuation_budget.py | 4 +- .../tools/test_delegate_cron_sync_fallback.py | 3 +- .../test_finalize_session_persist.py | 7 +- 15 files changed, 99 insertions(+), 279 deletions(-) create mode 100644 evals/session_snapshot_removal.py create mode 100644 tests/agent/test_session_snapshot_removal.py diff --git a/CONTRIBUTING.es.md b/CONTRIBUTING.es.md index 0ef0023da0..0c8915ecf8 100644 --- a/CONTRIBUTING.es.md +++ b/CONTRIBUTING.es.md @@ -210,7 +210,7 @@ hermes-agent/ | `~/.hermes/skills/` | Todas las habilidades activas (incluidas + instaladas desde hub + creadas por el agente) | | `~/.hermes/memories/` | Memoria persistente (MEMORY.md, USER.md) | | `~/.hermes/state.db` | Base de datos de sesiones SQLite | -| `~/.hermes/sessions/` | Índice de enrutamiento del gateway (`sessions.json`), migas de pan de solicitudes, transcripciones `*.jsonl` del gateway y (opcionalmente) snapshots JSON por sesión cuando `sessions.write_json_snapshots: true` está configurado. Los snapshots por sesión están desactivados por defecto; state.db es canónica. | +| `~/.hermes/sessions/` | Índice de enrutamiento del gateway (`sessions.json`), migas de pan de solicitudes, transcripciones `*.jsonl` del gateway y exportaciones explícitas con `/save`. Ya no se escriben snapshots JSON automáticos; los archivos existentes se conservan y state.db es canónica. | | `~/.hermes/cron/` | Datos de trabajos programados | | `~/.hermes/whatsapp/session/` | Credenciales del puente WhatsApp | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3173510bd2..0e6761ee18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -300,7 +300,7 @@ hermes-agent/ | `~/.hermes/skills/` | All active skills (bundled + hub-installed + agent-created) | | `~/.hermes/memories/` | Persistent memory (MEMORY.md, USER.md) | | `~/.hermes/state.db` | SQLite session database | -| `~/.hermes/sessions/` | Gateway routing index (`sessions.json`), request-dump breadcrumbs, gateway `*.jsonl` transcripts, and (optionally) per-session JSON snapshots when `sessions.write_json_snapshots: true` is set. The per-session snapshots are off by default; state.db is canonical. | +| `~/.hermes/sessions/` | Gateway routing index (`sessions.json`), request-dump breadcrumbs, gateway `*.jsonl` transcripts, and explicit `/save` exports. Automatic per-session JSON snapshots are no longer written; state.db is canonical. | | `~/.hermes/cron/` | Scheduled job data | | `~/.hermes/whatsapp/session/` | WhatsApp bridge credentials | @@ -329,7 +329,7 @@ User message → AIAgent._run_agent_loop() - **Self-registering tools**: Each tool file calls `registry.register()` at import time. `model_tools.py` triggers discovery by importing all tool modules. - **Toolset grouping**: Tools are grouped into toolsets (`web`, `terminal`, `file`, `browser`, etc.) that can be enabled/disabled per platform. -- **Session persistence**: All conversations are stored in SQLite (`hermes_state.py`) with full-text search and unique session titles. Per-session JSON snapshots in `~/.hermes/sessions/` were superseded by the SQLite store and are off by default; opt back in with `sessions.write_json_snapshots: true` if you have external tooling that consumes the JSON files directly. +- **Session persistence**: All conversations are stored in SQLite (`hermes_state.py`) with full-text search and unique session titles. Automatic per-session JSON snapshots have been removed. Existing files are left untouched; use `/save json` or `hermes sessions export` for an explicit export. - **Ephemeral injection**: System prompts and prefill messages are injected at API call time, never persisted to the database or logs. - **Provider abstraction**: The agent works with any OpenAI-compatible API. Provider resolution happens at init time (Nous Portal OAuth, OpenRouter API key, or custom endpoint). - **Provider routing**: When using OpenRouter, `provider_routing` in config.yaml controls provider selection (sort by throughput/latency/price, allow/ignore specific providers, data retention policies). These are injected as `extra_body.provider` in API requests. diff --git a/agent/agent_init.py b/agent/agent_init.py index 63e1dbc919..bd63274cb8 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1131,13 +1131,6 @@ def _init_session_state(agent, session_id, session_db, parent_session_id, reason # ~/.hermes/sessions/ — kept unconditionally for request_dump_*.json debug breadcrumbs. agent.logs_dir = get_hermes_home() / "sessions" agent.logs_dir.mkdir(parents=True, exist_ok=True) - # Per-session JSON snapshot is opt-in (sessions.write_json_snapshots); state.db is canonical. - agent._session_json_enabled = False - with suppress(Exception): - from hermes_cli.config import load_config_readonly as _load_sess_cfg - _sess_cfg = (_load_sess_cfg().get("sessions") or {}) - agent._session_json_enabled = bool(_sess_cfg.get("write_json_snapshots", False)) - _set_defaults(agent, _SESSION_STATE) # Filesystem checkpoint manager (transparent — not a tool) diff --git a/agent/background_review.py b/agent/background_review.py index 28b9991c9e..b812b3cf11 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -851,7 +851,7 @@ def build_cache_parity_fork( # finalize the parent's still-active session row. suppress_status_output: fork status/warning # emits go via _print_fn/status_callback, which bypass the stdout redirect. review_agent._skip_mcp_refresh = review_agent._persist_disabled = review_agent.suppress_status_output = True - review_agent._session_json_enabled = review_agent._end_session_on_close = False + review_agent._end_session_on_close = False review_agent._session_db = None review_agent.session_id = agent.session_id # Same model only: share the warm cached system prompt (~26% cost cut; a rebuilt prompt misses diff --git a/agent/session_persistence.py b/agent/session_persistence.py index 544883d6a1..4817209b1a 100644 --- a/agent/session_persistence.py +++ b/agent/session_persistence.py @@ -1,12 +1,12 @@ """Durable transcript persistence for ``AIAgent`` (mixin; MRO-resolved from ``run_agent``): SQLite flush -with intrinsic ``_DB_PERSISTED_MARKER`` dedup, ephemeral-scaffolding filtering, optional JSON session log, +with intrinsic ``_DB_PERSISTED_MARKER`` dedup, ephemeral-scaffolding filtering, explicit trajectory export.""" import hashlib -import json + import logging import re from contextlib import nullcontext -from datetime import datetime + from typing import Any, Dict, List, Optional from agent.context_compressor import ( @@ -17,11 +17,11 @@ from agent.context_compressor import ( ) from agent.lazy_forward import forward as _forward, forward_static as _forward_static from agent.memory_manager import sanitize_context -from agent.redact import redact_sensitive_text + from agent.tool_dispatch_helpers import _is_multimodal_tool_result, _multimodal_text_summary -from agent.trajectory import convert_scratchpad_to_think, save_trajectory as _save_trajectory_to_file +from agent.trajectory import save_trajectory as _save_trajectory_to_file from agent.transcript_repair import sync_flushed_message_markers -from utils import atomic_json_write + logger = logging.getLogger("run_agent") # origin module's name: log records / caplog filters unchanged @@ -262,34 +262,8 @@ def _db_flush_failed(agent, e: Exception, batch_rows: List[Dict[str, Any]], adop return False -def _session_log_entry(agent, msg: Dict[str, Any]) -> Dict[str, Any]: - """Copy of ``msg`` with scratchpad tags normalised and credentials redacted (honours HERMES_REDACT_SECRETS).""" - if "content" not in msg: - return msg - content = msg["content"] - if msg.get("role") == "assistant" and content: - content = agent._clean_session_content(content) - return {**msg, "content": agent._redact_message_content(content)} - - -def _existing_log_is_larger(log_file, count: int) -> bool: - """Never overwrite a larger log with fewer messages (resumed agent with partial history); a corrupted - existing file allows the overwrite.""" - if not log_file.exists(): - return False - try: - existing = json.loads(log_file.read_text(encoding="utf-8")) - existing_count = existing.get("message_count", len(existing.get("messages", []))) - except Exception: - return False - if existing_count > count: - logging.debug("Skipping session log overwrite: existing has %d messages, current has %d", existing_count, count) - return True - return False - - class SessionPersistenceMixin: - """Session DB flush, session log and trajectory persistence (see module docstring).""" + """Session DB flush and trajectory persistence (see module docstring).""" def _apply_persist_user_message_override(self, messages: List[Dict]) -> None: """Rewrite the current-turn user message in place: some paths send an API-only variant that must not @@ -311,7 +285,7 @@ class SessionPersistenceMixin: msg["platform_message_id"] = platform_id def _persist_session(self, messages: List[Dict], conversation_history: List[Dict] = None): - """Save to JSON log and SQLite on any exit path. Trailing empty-response scaffolding is dropped from + """Save to SQLite on any exit path. Trailing empty-response scaffolding is dropped from the live list; the persist override is applied to the DB row only. The persist user-message *override* is NOT applied here — it is resolved inside @@ -322,7 +296,6 @@ class SessionPersistenceMixin: with _persist_lock(self): self._drop_trailing_empty_response_scaffolding(messages) self._session_messages = messages - self._save_session_log(messages) self._flush_messages_to_session_db(messages, conversation_history) # Drain async token-accounting deltas at every persist point; cheap no-op when nothing queued. if self._session_db is not None: @@ -413,49 +386,3 @@ class SessionPersistenceMixin: _extract_api_error_context = _forward_static("agent.agent_runtime_helpers", "extract_api_error_context") _dump_api_request_debug = _forward("agent.agent_runtime_helpers", "dump_api_request_debug") - - @staticmethod - def _clean_session_content(content: str) -> str: - """Convert REASONING_SCRATCHPAD to think tags and clean up whitespace.""" - if not content: - return content - content = re.sub(r'\n+()', r'\n\1', convert_scratchpad_to_think(content)) - return re.sub(r'()\n+', r'\1\n', content).strip() - - @staticmethod - def _redact_message_content(content): - """Redact secrets in str or list-of-parts content (text fields only; honours HERMES_REDACT_SECRETS).""" - if isinstance(content, str): - return redact_sensitive_text(content) - if not isinstance(content, list): - return content - return [{**p, **{k: redact_sensitive_text(p[k]) for k in ("text", "content") if isinstance(p.get(k), str)}} - if isinstance(p, dict) else p for p in content] - - def _save_session_log(self, messages: List[Dict[str, Any]] = None): - """Optional per-session JSON snapshot (``sessions.write_json_snapshots``, default False) for external - tooling; state.db is canonical. Rewrites the full list after every persistence point.""" - if not getattr(self, "_session_json_enabled", False): - return - messages = messages or self._session_messages - if not messages: - return - try: # re-derive the path each call so /branch and /compress land in the right file - log_file = self.logs_dir / f"session_{_safe_session_filename_component(self.session_id)}.json" - except Exception: - return - try: - # Mirror the SQLite flush: scaffolding is never durable transcript content. - cleaned = [_session_log_entry(self, msg) for msg in messages if not _is_ephemeral_scaffolding(msg)] - if _existing_log_is_larger(log_file, len(cleaned)): - return - entry = { - "session_id": self.session_id, "model": self.model, "base_url": self.base_url, "platform": self.platform, - "session_start": self.session_start.isoformat(), "last_updated": datetime.now().isoformat(), - "system_prompt": redact_sensitive_text(self._cached_system_prompt or ""), "tools": self.tools or [], - "message_count": len(cleaned), "messages": cleaned, - } - atomic_json_write(log_file, entry, indent=2, default=str) - except Exception as e: - if self.verbose_logging: - logging.warning(f"Failed to save session log: {e}") diff --git a/evals/session_snapshot_removal.py b/evals/session_snapshot_removal.py new file mode 100644 index 0000000000..81023a7d8f --- /dev/null +++ b/evals/session_snapshot_removal.py @@ -0,0 +1,36 @@ +"""Real config → SQLite persistence → explicit /save A/B, without provider calls. + +Run with a clean HOME/HERMES_HOME and PYTHONPATH pointing at the tree under test. +""" +import json +import os +from pathlib import Path +from types import SimpleNamespace + +home = Path(os.environ["HERMES_HOME"]) +home.mkdir(parents=True, exist_ok=True) +(home / "config.yaml").write_text("sessions:\n write_json_snapshots: true\n", encoding="utf-8") +from agent.agent_init import _init_session_state +from agent.session_persistence import SessionPersistenceMixin +from hermes_cli.cli_session_mixin import CLISessionMixin +from hermes_state import SessionDB + +with SessionDB(db_path=home / "state.db") as db: + agent = SessionPersistenceMixin() + agent.max_iterations = 1 + _init_session_state(agent, "snapshot-probe", db, None, None, None, False, 1, 1, 1) + agent.model, agent.base_url, agent.platform = "fixture", "http://127.0.0.1:1/v1", "cli" + agent.tools, agent.verbose_logging = [], True + db.create_session(agent.session_id, source="cli", model=agent.model) + agent._session_db_created = True + messages = [{"role": "user", "content": "retained question"}, + {"role": "assistant", "content": "retained answer"}] + agent._persist_session(messages) + snapshots = [p.name for p in agent.logs_dir.glob("session_*.json")] + cli = SimpleNamespace(_session_db=db, session_id=agent.session_id) + explicit = home / "explicit.json" + CLISessionMixin.save_conversation(cli, f"/save json {explicit}") + saved = json.loads(explicit.read_text(encoding="utf-8")) + print(json.dumps({"automatic_snapshots": snapshots, + "sqlite_messages": len(db.get_messages_as_conversation(agent.session_id)), + "explicit_saved_messages": [m["content"] for m in saved["messages"]]})) diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 151e08dc32..f326738c0a 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2023,10 +2023,7 @@ DEFAULT_CONFIG = { # Minimum hours between auto-maintenance runs (tracked in state.db state_meta, shared across # processes). "min_interval_hours": 24, - # Legacy ~/.hermes/sessions/session_{sid}.json snapshots rewritten every turn. state.db is - # canonical (superset); snapshots consumed GBs on heavy users. Enable only for an external - # tool that reads the JSON files directly. - "write_json_snapshots": False, + # Notice about the compact FTS layout (reclaims ~60%+ of state.db). OPT-IN: legacy indexes # stay until `hermes sessions optimize-storage` runs, since the rebuild is disk-heavy on # large DBs. advise = `hermes update` prints a one-line notice with reclaimable size when a diff --git a/tests/agent/test_session_snapshot_removal.py b/tests/agent/test_session_snapshot_removal.py new file mode 100644 index 0000000000..3f60d6f7bf --- /dev/null +++ b/tests/agent/test_session_snapshot_removal.py @@ -0,0 +1,42 @@ +"""Automatic persistence stays in SQLite; explicit exports remain available.""" +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from agent.agent_init import _init_session_state +from agent.session_persistence import SessionPersistenceMixin +from hermes_state import SessionDB + + +@pytest.mark.parametrize("legacy_enabled", [False, True]) +def test_persistence_never_snapshots_but_explicit_save_works(tmp_path, monkeypatch, legacy_enabled): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text( + f"sessions:\n write_json_snapshots: {str(legacy_enabled).lower()}\n", encoding="utf-8") + db = SessionDB(db_path=tmp_path / "state.db") + agent = SessionPersistenceMixin() + agent.max_iterations = 1 + _init_session_state(agent, "snapshot-removal", db, None, None, None, False, 1, 1, 1) + agent.model = "fixture" + agent.base_url = "http://127.0.0.1:1/v1" + agent.platform = "cli" + agent.tools = [] + agent.verbose_logging = True + db.create_session(agent.session_id, source="cli", model=agent.model) + agent._session_db_created = True + messages = [{"role": "user", "content": "keep this conversation"}, + {"role": "assistant", "content": "durable answer"}] + try: + agent._persist_session(messages) + assert len(db.get_messages_as_conversation(agent.session_id)) == 2 + assert not list(agent.logs_dir.glob("session_*.json")) + from hermes_cli.cli_session_mixin import CLISessionMixin + cli = SimpleNamespace(_session_db=db, session_id=agent.session_id) + output = tmp_path / "explicit.json" + CLISessionMixin.save_conversation(cli, f"/save json {output}") + saved = json.loads(output.read_text(encoding="utf-8")) + assert [m["content"] for m in saved["messages"]] == [m["content"] for m in messages] + finally: + db.close() diff --git a/tests/agent/test_verification_stop_caching.py b/tests/agent/test_verification_stop_caching.py index 5678104031..7e88f682ea 100644 --- a/tests/agent/test_verification_stop_caching.py +++ b/tests/agent/test_verification_stop_caching.py @@ -12,7 +12,6 @@ gets stripped from the durable transcript. This test file verifies: - The JSON log drops only the nudge, keeping the assistant candidate. """ -import json import sys from unittest.mock import MagicMock @@ -85,7 +84,7 @@ def _make_agent(ra, session_id, tmp_path): ) agent._session_db = MagicMock() agent._session_db_created = True - agent._session_json_enabled = True + agent.logs_dir = tmp_path / "logs" agent.logs_dir.mkdir(parents=True, exist_ok=True) return agent @@ -120,34 +119,3 @@ def test_db_flush_drops_only_nudge_keeps_candidate(tmp_path, monkeypatch): assert "premature done" in persisted # Only the nudge is dropped. assert "[System: run tests]" not in persisted - - -def test_json_log_drops_only_nudge_keeps_candidate(tmp_path, monkeypatch): - """The assistant candidate is NOT flagged synthetic, so it persists in the - JSON log. Only the nudge (flagged synthetic) is dropped.""" - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) - ra = _fresh_run_agent(tmp_path) - agent = _make_agent(ra, "sess_json", tmp_path) - - messages = [ - {"role": "user", "content": "hi"}, - # Assistant candidate — NOT flagged synthetic, persists. - {"role": "assistant", "content": "premature done"}, - # Nudge — flagged synthetic, gets dropped. - {"role": "user", "content": "[System: run tests]", "_pre_verify_synthetic": True}, - {"role": "assistant", "content": "verified and clean"}, - ] - - agent._save_session_log(messages) - - log_file = agent.logs_dir / "session_sess_json.json" - assert log_file.exists() - data = json.loads(log_file.read_text(encoding="utf-8")) - contents = [m.get("content") for m in data["messages"]] - # The assistant candidate persists — it is real content. - assert "premature done" in contents - assert "verified and clean" in contents - assert "hi" in contents - # Only the nudge is dropped. - assert "[System: run tests]" not in contents - assert all(not m.get("_pre_verify_synthetic") for m in data["messages"]) diff --git a/tests/cli/test_cli_interrupt_ack_race.py b/tests/cli/test_cli_interrupt_ack_race.py index ee2b53b696..0b3d9c0e7b 100644 --- a/tests/cli/test_cli_interrupt_ack_race.py +++ b/tests/cli/test_cli_interrupt_ack_race.py @@ -270,7 +270,6 @@ def test_chat_multimodal_note_persists_clean_input_once(tmp_path, monkeypatch): agent._cached_system_prompt = "test system prompt" agent._session_init_model_config = None agent._parent_session_id = None - agent._session_json_enabled = False agent._pending_cli_user_message = None agent._session_persist_lock = threading.RLock() agent._persist_user_message_idx = None @@ -455,7 +454,6 @@ def test_close_waits_for_atomic_cli_staging_before_snapshot(tmp_path, monkeypatc agent._cached_system_prompt = "test system prompt" agent._session_init_model_config = None agent._parent_session_id = None - agent._session_json_enabled = False agent._pending_cli_user_message = None agent._session_persist_lock = threading.RLock() agent._persist_user_message_idx = None diff --git a/tests/cli/test_cli_shutdown_memory_messages.py b/tests/cli/test_cli_shutdown_memory_messages.py index 55aebee13b..e2b2f44918 100644 --- a/tests/cli/test_cli_shutdown_memory_messages.py +++ b/tests/cli/test_cli_shutdown_memory_messages.py @@ -17,7 +17,6 @@ other tests keep their existing no-arg behaviour. from __future__ import annotations import threading -import types from typing import Any from unittest.mock import MagicMock, patch @@ -98,7 +97,7 @@ def _real_agent(db, session_id, session_messages): agent._cached_system_prompt = "test system prompt" agent._session_init_model_config = None agent._parent_session_id = None - agent._session_json_enabled = False + agent._pending_cli_user_message = None agent._session_persist_lock = threading.RLock() return agent diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index e490ddd157..40bc52a06f 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -5,12 +5,9 @@ pieces. The OpenAI client and tool loading are mocked so no network calls are made. """ -import ast -import inspect import io import json import logging -import re import threading import time import uuid @@ -164,7 +161,7 @@ def test_direct_session_db_flushes_share_marker_claim(agent): agent._persist_user_message_timestamp = None agent._persist_disabled = False agent._session_persist_lock = threading.RLock() - agent._session_json_enabled = False + message = {"role": "user", "content": "exactly once"} normal = threading.Thread(target=lambda: agent._persist_session([message], [])) @@ -500,67 +497,7 @@ class TestExtractReasoning: -class TestSessionJsonSnapshotOptIn: - """Regression: per-session JSON snapshot writer is opt-in via config. - - state.db is canonical (PR #29182). ``sessions.write_json_snapshots`` - defaults to False, so the agent must NOT write ``session_{sid}.json`` - files by default — that behavior caused multi-GB sessions directories - on heavy users. Users can opt back in for external tooling that reads - the JSON files directly. - """ - - def test_session_json_disabled_by_default(self, agent): - # Default config: writer is gated off. - assert getattr(agent, "_session_json_enabled", False) is False, ( - "sessions.write_json_snapshots must default to False" - ) - - def test_save_session_log_noops_when_disabled(self, agent, tmp_path): - # When disabled, calling the method must not write any file even - # if logs_dir is writable and messages are non-empty. - agent._session_json_enabled = False - agent.logs_dir = tmp_path - agent._session_messages = [{"role": "user", "content": "hello"}] - agent._save_session_log() - # No session_*.json must appear under logs_dir. - assert list(tmp_path.glob("session_*.json")) == [] - - def test_save_session_log_writes_when_enabled(self, agent, tmp_path): - # Opt-in path: with the flag on and a session_id, the writer must - # produce ``session_{sid}.json`` under logs_dir. - agent._session_json_enabled = True - agent.logs_dir = tmp_path - messages = [{"role": "user", "content": "hello"}] - agent._save_session_log(messages) - expected = tmp_path / f"session_{agent.session_id}.json" - assert expected.exists(), ( - "Opt-in writer must produce session_{sid}.json under logs_dir" - ) - - def test_logs_dir_retained_for_request_dumps(self, agent): - # logs_dir is kept unconditionally because - # agent_runtime_helpers.dump_api_request_debug still writes - # request_dump_*.json there (debug breadcrumb path), independent of - # the session JSON opt-in. - assert hasattr(agent, "logs_dir") - - def test_traversal_session_id_cannot_escape_logs_dir(self, agent, tmp_path): - # Security regression (#5958): a traversal-shaped session ID (which can - # originate from the untrusted X-Hermes-Session-Id API header) must not - # redirect the session snapshot outside the sessions directory. - agent._session_json_enabled = True - agent.logs_dir = tmp_path - agent.session_id = "../../../../outside_dir/pwned" - agent._save_session_log([{"role": "user", "content": "hello"}]) - - # Exactly one snapshot, and it lives directly under logs_dir. - written = list(tmp_path.glob("session_*.json")) - assert len(written) == 1, "writer must produce a single contained snapshot" - assert written[0].resolve().parent == tmp_path.resolve() - # Nothing escaped to the traversal target. - assert not (tmp_path.parent.parent / "outside_dir").exists() - +class TestSessionFilenameSafety: def test_safe_session_filename_component_contains_traversal(self): # The sanitizer is the chokepoint: every session-ID-derived artifact # path goes through it, so it must always yield a single, traversal-free @@ -574,76 +511,6 @@ class TestSessionJsonSnapshotOptIn: assert f("../a") != f("../b") -class TestSaveSessionLogRedactsSecrets: - """Regression: session_*.json must not contain plaintext credentials (#19798, #19845).""" - - @pytest.fixture(autouse=True) - def _ensure_redaction_enabled(self, monkeypatch): - """Force redaction on regardless of host HERMES_REDACT_SECRETS state. - The hermetic conftest blanks the env var; the module-level - ``_REDACT_ENABLED`` constant is captured at import time, so we - flip it directly for the duration of these tests.""" - monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False) - monkeypatch.setattr("agent.redact._REDACT_ENABLED", True) - - def test_redacts_api_key_in_tool_content(self, agent, tmp_path): - agent._session_json_enabled = True - agent.logs_dir = tmp_path - messages = [ - {"role": "user", "content": "Hello"}, - { - "role": "tool", - "content": "Response: Authorization: Bearer sk-proj-abc123def456ghi789jkl012mno", - }, - ] - agent._save_session_log(messages) - - snapshot = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8") - assert "sk-proj-abc123def456ghi789jkl012mno" not in snapshot - - def test_redacts_api_key_in_user_message(self, agent, tmp_path): - agent._session_json_enabled = True - agent.logs_dir = tmp_path - messages = [ - {"role": "user", "content": "My key is sk-ant-api03-abc123def456ghi789jkl012mno please use it"}, - ] - agent._save_session_log(messages) - - snapshot = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8") - assert "sk-ant-api03-abc123def456ghi789jkl012mno" not in snapshot - - def test_redacts_system_prompt_credentials(self, agent, tmp_path): - agent._session_json_enabled = True - agent.logs_dir = tmp_path - agent._cached_system_prompt = "Use key sk-proj-realkey1234567890123456 for API calls" - agent._save_session_log([{"role": "user", "content": "test"}]) - - snapshot = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8") - assert "sk-proj-realkey1234567890123456" not in snapshot - - def test_redacts_list_type_multimodal_content(self, agent, tmp_path): - """OpenAI/Anthropic multimodal shape: content = list of {type, text|image_url} parts.""" - agent._session_json_enabled = True - agent.logs_dir = tmp_path - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "Key: gsk_abc123def456ghi789jkl012mno"}, - {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, - ], - }, - ] - agent._save_session_log(messages) - - snapshot_text = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8") - snapshot = json.loads(snapshot_text) - parts = snapshot["messages"][0]["content"] - assert "gsk_abc123def456ghi789jkl012mno" not in parts[0]["text"] - # Image part preserved untouched - assert parts[1]["image_url"]["url"].startswith("data:image") - - class TestGetMessagesUpToLastAssistant: def test_empty_list(self, agent): assert agent._get_messages_up_to_last_assistant([]) == [] @@ -6760,7 +6627,6 @@ class TestAnthropicInterruptHandler: Replaces the former source-reading assertion (which asserted the old, now-removed rebuild-on-interrupt behavior) with a behavior test. """ - import threading import time from unittest.mock import MagicMock from run_agent import AIAgent diff --git a/tests/run_agent/test_verification_continuation_budget.py b/tests/run_agent/test_verification_continuation_budget.py index 1ac4b57578..2231b2c170 100644 --- a/tests/run_agent/test_verification_continuation_budget.py +++ b/tests/run_agent/test_verification_continuation_budget.py @@ -38,7 +38,7 @@ def agent(tmp_path, monkeypatch): ) instance._cached_system_prompt = "stable test prompt" instance._session_db = None - instance._session_json_enabled = False + instance.save_trajectories = False instance.compression_enabled = False instance._cleanup_task_resources = lambda *_a, **_kw: None @@ -251,5 +251,3 @@ def test_streamed_interim_then_different_summary_not_marked_previewed(agent, mon # CRITICAL: response_previewed must be False — the interim narration was # NOT the final response, so the CLI must render the summary. assert result["response_previewed"] is False - - diff --git a/tests/tools/test_delegate_cron_sync_fallback.py b/tests/tools/test_delegate_cron_sync_fallback.py index 9dce06883d..1307e51e72 100644 --- a/tests/tools/test_delegate_cron_sync_fallback.py +++ b/tests/tools/test_delegate_cron_sync_fallback.py @@ -26,7 +26,6 @@ import threading from types import SimpleNamespace from unittest.mock import MagicMock, patch -import pytest import tools.delegate_tool as dt @@ -83,7 +82,7 @@ def _make_real_child(): # Keep the test hermetic: no session persistence. child._persist_disabled = True child._session_db = None - child._session_json_enabled = False + return child diff --git a/tests/tui_gateway/test_finalize_session_persist.py b/tests/tui_gateway/test_finalize_session_persist.py index 13dd73bfc9..f5ddf7aa35 100644 --- a/tests/tui_gateway/test_finalize_session_persist.py +++ b/tests/tui_gateway/test_finalize_session_persist.py @@ -12,10 +12,8 @@ Scenarios: """ import threading -import time -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import MagicMock, patch -import pytest # --------------------------------------------------------------------------- # Helpers @@ -177,7 +175,7 @@ class TestFinalizeSessionPersistE2E: agent._cached_system_prompt = None agent._session_init_model_config = None agent._parent_session_id = None - agent._session_json_enabled = False + agent.quiet_mode = True # commit_memory_session runs heavy machinery we don't exercise here. agent.commit_memory_session = lambda *a, **k: None @@ -270,4 +268,3 @@ class TestOnSessionEndHook: model="claude-sonnet-4", platform="tui", ) -