18df2fe7b4
Standalone Python stdlib pipeline that reads an agent's past sessions, compares them against installed skills, and generates structured improvement proposals gated by an evaluation framework before anything mutates. Host-agnostic via HostAdapter (Hermes, Claude Code). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
331 lines
14 KiB
Python
331 lines
14 KiB
Python
"""Tests for scripts/fetch_sessions.py's fetch_sessions().
|
|
|
|
Regression coverage for a bug found during code review: the per-message loop
|
|
used `msg.get("content", "")` on a `sqlite3.Row`, which has no `.get()` method
|
|
(`AttributeError: 'sqlite3.Row' object has no attribute 'get'`) -- so
|
|
fetch_sessions() crashed on any session that had at least one message. Fixed
|
|
by extracting the shared `_summarize_messages()` helper (also used by
|
|
sessions_for_skill()), which uses bracket access throughout.
|
|
|
|
The fixture schema here matches the live ~/.hermes/state.db schema
|
|
(`sessions.started_at`, no `sessions.total_tokens`; `messages.timestamp`, no
|
|
`messages.created_at`) -- see sessions_for_skill()'s own
|
|
SKILL_SESSION_QUERY/SKILL_MESSAGE_QUERY, which were already written against
|
|
these same live column names.
|
|
"""
|
|
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
|
|
|
import pytest
|
|
|
|
import fetch_sessions
|
|
|
|
|
|
def _make_db(path):
|
|
conn = sqlite3.connect(path)
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
source TEXT NOT NULL,
|
|
model TEXT,
|
|
started_at REAL NOT NULL,
|
|
title TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT,
|
|
timestamp REAL NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
conn.commit()
|
|
return conn
|
|
|
|
|
|
@pytest.fixture
|
|
def db_path(tmp_path, monkeypatch):
|
|
path = str(tmp_path / "state.db")
|
|
_make_db(path).close()
|
|
monkeypatch.setattr(fetch_sessions, "STATE_FILE", str(tmp_path / "skill_evolution_state.json"))
|
|
return path
|
|
|
|
|
|
def test_fetch_sessions_with_messages_does_not_crash(db_path):
|
|
"""Regression: previously raised AttributeError on any session with messages."""
|
|
conn = sqlite3.connect(db_path)
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, model, started_at, title) VALUES (?, ?, ?, ?, ?)",
|
|
("sess-1", "claude-code", "claude", 1000.0, "a session"),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
|
("sess-1", "user", "hello there", 1000.0),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
|
("sess-1", "assistant", "hi, how can I help?", 1001.0),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
results = fetch_sessions.fetch_sessions(db_path=db_path, lookback_hours=999999, dry_run=True)
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["session_id"] == "sess-1"
|
|
assert results[0]["started_at"] == 1000.0
|
|
assert "total_tokens" not in results[0]
|
|
assert results[0]["user_messages"] == 1
|
|
assert results[0]["assistant_messages"] == 1
|
|
contents = [m["content_preview"] for m in results[0]["messages"]]
|
|
assert "hello there" in contents
|
|
assert "hi, how can I help?" in contents
|
|
|
|
|
|
def test_fetch_sessions_redacts_secret_in_message(db_path):
|
|
conn = sqlite3.connect(db_path)
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, model, started_at, title) VALUES (?, ?, ?, ?, ?)",
|
|
("sess-secret", "claude-code", "claude", 1000.0, "t"),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
|
("sess-secret", "user", "here is my key sk-ant-api03-abc123", 1000.0),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
results = fetch_sessions.fetch_sessions(db_path=db_path, lookback_hours=999999, dry_run=True)
|
|
|
|
assert results[0]["messages"] == []
|
|
|
|
|
|
def test_fetch_sessions_filters_by_lookback_window(db_path):
|
|
"""R2: a session whose started_at is outside --lookback-hours is excluded;
|
|
one inside the window is included."""
|
|
import time
|
|
|
|
now = time.time()
|
|
conn = sqlite3.connect(db_path)
|
|
# Well outside a 48-hour lookback window (10 days ago).
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, model, started_at, title) VALUES (?, ?, ?, ?, ?)",
|
|
("sess-old", "claude-code", "claude", now - (10 * 24 * 3600), "old session"),
|
|
)
|
|
# Well inside a 48-hour lookback window (1 hour ago).
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, model, started_at, title) VALUES (?, ?, ?, ?, ?)",
|
|
("sess-recent", "claude-code", "claude", now - 3600, "recent session"),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
results = fetch_sessions.fetch_sessions(db_path=db_path, lookback_hours=48, dry_run=True)
|
|
|
|
session_ids = {r["session_id"] for r in results}
|
|
assert session_ids == {"sess-recent"}
|
|
|
|
|
|
# ── Automatic state pruning (SKILL_EVOLUTION_STATE_RETENTION) ────────────
|
|
#
|
|
# Mirrors evaluate.py's auto-prune: a no-op unless the retention env var is configured,
|
|
# wired into fetch_sessions() itself (the only real orchestrator of prune_processed() --
|
|
# state.py has none of its own) right after mark_processed(), so it fires on every real,
|
|
# non-dry-run invocation including the unattended nightly cron run.
|
|
|
|
def _seed_state(path, entries):
|
|
import json
|
|
with open(path, "w") as f:
|
|
json.dump(entries, f)
|
|
|
|
|
|
def _insert_session(conn, session_id, started_at):
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, model, started_at, title) VALUES (?, ?, ?, ?, ?)",
|
|
(session_id, "claude-code", "claude", started_at, "t"),
|
|
)
|
|
|
|
|
|
def test_fetch_sessions_prunes_automatically_when_retention_configured(db_path, monkeypatch, tmp_path):
|
|
import time
|
|
now = time.time()
|
|
state_path = str(tmp_path / "skill_evolution_state.json")
|
|
monkeypatch.setattr(fetch_sessions, "STATE_FILE", state_path)
|
|
monkeypatch.setenv(fetch_sessions.STATE_RETENTION_ENV_VAR, "1d")
|
|
old_iso = "2020-01-01T00:00:00+00:00"
|
|
_seed_state(state_path, {"already-old": old_iso})
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
_insert_session(conn, "sess-new", now - 60)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
fetch_sessions.fetch_sessions(db_path=db_path, lookback_hours=48, dry_run=False)
|
|
|
|
processed = set(fetch_sessions.load_processed())
|
|
assert "already-old" not in processed # pruned: far older than the 1-day retention
|
|
assert "sess-new" in processed
|
|
|
|
|
|
def test_fetch_sessions_does_not_prune_when_retention_unset(db_path, monkeypatch, tmp_path):
|
|
import time
|
|
now = time.time()
|
|
state_path = str(tmp_path / "skill_evolution_state.json")
|
|
monkeypatch.setattr(fetch_sessions, "STATE_FILE", state_path)
|
|
monkeypatch.delenv(fetch_sessions.STATE_RETENTION_ENV_VAR, raising=False)
|
|
old_iso = "2020-01-01T00:00:00+00:00"
|
|
_seed_state(state_path, {"already-old": old_iso})
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
_insert_session(conn, "sess-new", now - 60)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
fetch_sessions.fetch_sessions(db_path=db_path, lookback_hours=48, dry_run=False)
|
|
|
|
processed = set(fetch_sessions.load_processed())
|
|
assert "already-old" in processed # regression lock: default behavior is unchanged
|
|
assert "sess-new" in processed
|
|
|
|
|
|
def test_fetch_sessions_keep_ids_protects_just_written_entries(db_path, monkeypatch, tmp_path):
|
|
"""The self-defeating-loop regression test: mark_processed() stamps an entire run's
|
|
batch with the same timestamp, so a naive recency-sort at a tight count retention has
|
|
no tiebreak among them. Without the keep_ids floor, this run's own new sessions could
|
|
be pruned in the very call that just wrote them."""
|
|
import time
|
|
now = time.time()
|
|
state_path = str(tmp_path / "skill_evolution_state.json")
|
|
monkeypatch.setattr(fetch_sessions, "STATE_FILE", state_path)
|
|
monkeypatch.setenv(fetch_sessions.STATE_RETENTION_ENV_VAR, "1") # count=1 -- as tight as it gets
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
for i in range(3):
|
|
_insert_session(conn, f"sess-new-{i}", now - 60 - i)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
fetch_sessions.fetch_sessions(db_path=db_path, lookback_hours=48, dry_run=False)
|
|
|
|
processed = set(fetch_sessions.load_processed())
|
|
# All three survive despite retention=1 -- a naive sort-and-truncate would have kept
|
|
# only one of them, since they share this run's single now() timestamp.
|
|
assert processed == {"sess-new-0", "sess-new-1", "sess-new-2"}
|
|
|
|
|
|
def test_fetch_sessions_dry_run_never_prunes(db_path, monkeypatch, tmp_path):
|
|
state_path = str(tmp_path / "skill_evolution_state.json")
|
|
monkeypatch.setattr(fetch_sessions, "STATE_FILE", state_path)
|
|
monkeypatch.setenv(fetch_sessions.STATE_RETENTION_ENV_VAR, "1")
|
|
|
|
def fail_if_called(*a, **kw):
|
|
raise AssertionError("prune_processed must not be called during --dry-run")
|
|
monkeypatch.setattr(fetch_sessions, "prune_processed", fail_if_called)
|
|
|
|
fetch_sessions.fetch_sessions(db_path=db_path, lookback_hours=48, dry_run=True) # must not raise
|
|
|
|
|
|
def test_fetch_sessions_warns_when_state_retention_below_lookback(db_path, monkeypatch, tmp_path, capsys):
|
|
import time
|
|
now = time.time()
|
|
state_path = str(tmp_path / "skill_evolution_state.json")
|
|
monkeypatch.setattr(fetch_sessions, "STATE_FILE", state_path)
|
|
monkeypatch.setenv(fetch_sessions.STATE_RETENTION_ENV_VAR, "1d") # 24h < the 48h lookback below
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
_insert_session(conn, "sess-new", now - 60)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
fetch_sessions.fetch_sessions(db_path=db_path, lookback_hours=48, dry_run=False)
|
|
|
|
err = capsys.readouterr().err
|
|
assert "SKILL_EVOLUTION_STATE_RETENTION" in err
|
|
assert "lookback" in err.lower()
|
|
|
|
|
|
def test_fetch_sessions_no_warning_when_state_retention_at_or_above_lookback(db_path, monkeypatch, tmp_path, capsys):
|
|
import time
|
|
now = time.time()
|
|
state_path = str(tmp_path / "skill_evolution_state.json")
|
|
monkeypatch.setattr(fetch_sessions, "STATE_FILE", state_path)
|
|
monkeypatch.setenv(fetch_sessions.STATE_RETENTION_ENV_VAR, "30d") # far above the 48h lookback
|
|
# A fresh state file starts documented-shape (mark_processed()'s own fallback), which
|
|
# prune_processed() can never prune and always warns about -- seed a pre-existing
|
|
# flat-dict entry so the file stays flat-dict-shaped, matching how the real deployed
|
|
# file actually got that shape, and exercising the branch this test is about.
|
|
_seed_state(state_path, {"already-processed": "2026-01-01T00:00:00+00:00"})
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
_insert_session(conn, "sess-new", now - 60)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
fetch_sessions.fetch_sessions(db_path=db_path, lookback_hours=48, dry_run=False)
|
|
|
|
assert "SKILL_EVOLUTION_STATE_RETENTION" not in capsys.readouterr().err
|
|
|
|
|
|
def test_cross_run_reprocessing_gap_is_a_documented_limitation(db_path, monkeypatch, tmp_path):
|
|
"""Documents, rather than fixes, the one hole keep_ids doesn't close.
|
|
|
|
keep_ids only protects a run's own writes from itself, so a session freshly marked
|
|
processed can never be pruned in the same call. The gap is cross-run: a session marked
|
|
processed *in the past* (here seeded directly, simulating a real prior run) ages past
|
|
the configured retention and gets pruned from state on this run's prune_processed()
|
|
call -- but if it's still inside a later run's --lookback-hours window (a deliberately
|
|
wide 200h one here), it no longer looks "already processed" and gets re-fetched. The
|
|
mitigation in scope is the warning tested above, not fixing this -- operators should
|
|
keep retention >= lookback_hours.
|
|
"""
|
|
import time
|
|
now = time.time()
|
|
state_path = str(tmp_path / "skill_evolution_state.json")
|
|
monkeypatch.setattr(fetch_sessions, "STATE_FILE", state_path)
|
|
monkeypatch.setenv(fetch_sessions.STATE_RETENTION_ENV_VAR, "1d")
|
|
# Seeded as already processed, well past the 1-day retention -- simulating a session a
|
|
# real prior run (not this test) marked processed a long time ago.
|
|
_seed_state(state_path, {"sess-borderline": "2020-01-01T00:00:00+00:00"})
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
# Still inside a deliberately wide (misconfigured) 200-hour lookback window.
|
|
_insert_session(conn, "sess-borderline", now - (3 * 24 * 3600))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Run 1: already in `processed`, so the query excludes it -- nothing "new" this run,
|
|
# but prune_processed() removes its now-ancient state entry regardless (it isn't in
|
|
# this run's keep_ids, since nothing was newly processed).
|
|
first = fetch_sessions.fetch_sessions(db_path=db_path, lookback_hours=200, dry_run=False)
|
|
assert first == []
|
|
assert "sess-borderline" not in set(fetch_sessions.load_processed())
|
|
|
|
# Run 2: with the state entry gone and the session still inside the lookback window,
|
|
# it no longer looks processed -- re-fetched. This is the documented gap.
|
|
second = fetch_sessions.fetch_sessions(db_path=db_path, lookback_hours=200, dry_run=False)
|
|
assert {r["session_id"] for r in second} == {"sess-borderline"}
|
|
|
|
|
|
def test_prune_state_cli_reports_to_stderr_and_returns_before_touching_the_db(monkeypatch, tmp_path, capsys):
|
|
state_path = str(tmp_path / "skill_evolution_state.json")
|
|
monkeypatch.setattr(fetch_sessions, "STATE_FILE", state_path)
|
|
_seed_state(state_path, {"old": "2020-01-01T00:00:00+00:00"})
|
|
monkeypatch.setenv(fetch_sessions.STATE_RETENTION_ENV_VAR, "1d")
|
|
|
|
def fail_if_called(*a, **kw):
|
|
raise AssertionError("--prune-state must not call fetch_sessions()")
|
|
monkeypatch.setattr(fetch_sessions, "fetch_sessions", fail_if_called)
|
|
monkeypatch.setattr(sys, "argv", ["fetch_sessions.py", "--prune-state"])
|
|
|
|
fetch_sessions.main() # must not raise, must not call fetch_sessions()
|
|
|
|
captured = capsys.readouterr()
|
|
assert captured.out == "" # nothing on the NDJSON channel
|
|
assert "Pruned state" in captured.err
|