Files
hermes-webui/tests/test_issue3585_cron_session_overflow.py
nesquena-hermes 4cf40a317a
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.264 — Release IF (stage-r14) (#3636)
## Release v0.51.264 — Release IF (stage-r14)

Un-held sibling pair (#3585 + #3586) — both addressed the findings from the earlier hold; re-reviewed fresh.

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3585 | @rodboev | Cron sessions no longer flood the CLI sidebar window (restored the `("cron","webui")` exclusion in `_load_cli_sessions_uncached`). |
| #3586 | @rodboev | Messaging sessions keep their source label after a refresh **and open + send correctly** — `is_cli_session_row()` classifies them non-CLI, and the sidebar open/import path now uses `_isMessagingSession()` so a reclassified Discord/Telegram/Slack row is imported on open (no transient stub → no `/api/chat/start` 404). |

### Un-hold note
These were held earlier today because the `is_cli_session_row()` reclassification (#3586) created a CORE open-path regression — opening a reclassified messaging session 404'd on the next send. The author pushed a fix adding the `_isMessagingSession()` import gate at all open/lineage/refresh paths (+ regression test `test_issue3603_external_session_import_gate.py`), and Codex confirmed both that AND the secondary webui-recovery concern (cron-only exclusion now keeps `source='webui'` sidecar-less recovery rows) are resolved.

### Gate
- Full pytest suite: **7729 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — open→import→send path verified (messaging rows go through `/api/session/import_cli` before `/api/chat/start`); `is_cli_session_row` classification correct; the pair composes in `_load_cli_sessions_uncached`.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-05 00:11:48 -07:00

204 lines
7.6 KiB
Python

"""Regression coverage for cron sessions squeezing out Discord/Telegram rows (#3585).
When ``_load_cli_sessions_uncached`` passed ``exclude_sources=None`` to
``read_importable_agent_session_rows``, the 20-row window filled with cron
entries, hiding Discord/Telegram sessions entirely. The fix narrows the
exclusion to ``("cron",)`` so cron rows stay out of the main pass (the cron
second-pass recovers them independently) while ``source='webui'`` rows remain
visible for sidecar-less recovery.
"""
import pathlib
import sqlite3
import time
from unittest import mock
import api.models as models
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
def _make_state_db(path, *, cron_count=15, discord_count=5):
"""Create a state.db with cron and discord sessions.
Cron sessions are created with newer timestamps so they dominate the
ORDER BY window when ``exclude_sources`` is not applied.
"""
conn = sqlite3.connect(str(path))
conn.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
session_source TEXT,
title TEXT,
model TEXT,
started_at REAL NOT NULL,
message_count INTEGER DEFAULT 0,
parent_session_id TEXT,
ended_at REAL,
end_reason TEXT
);
CREATE INDEX idx_sessions_started ON sessions(started_at);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT,
role TEXT,
content TEXT,
timestamp REAL
);
CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
"""
)
# Cron sessions: newest timestamps (highest integers) to dominate window
now = time.time()
for i in range(cron_count):
sid = f"cron_job_{i:04d}_{int(now) + i}"
started = now + i # newer than discord sessions
conn.execute(
"""
INSERT INTO sessions
(id, source, session_source, title, model, started_at, message_count,
parent_session_id, ended_at, end_reason)
VALUES (?, 'cron', 'cron', ?, 'deepseek/deepseek-chat', ?, 1, NULL, NULL, NULL)
""",
(sid, f"Cron job {i}", started),
)
conn.execute(
"INSERT INTO messages (id, session_id, role, content, timestamp)"
" VALUES (?, ?, 'user', 'cron task', ?)",
(f"cron_msg_{i:04d}", sid, started),
)
# Discord sessions: older timestamps so they lose to cron in raw ORDER BY
for i in range(discord_count):
sid = f"discord_{i:04d}"
started = now - 100 + i # older than cron sessions
conn.execute(
"""
INSERT INTO sessions
(id, source, session_source, title, model, started_at, message_count,
parent_session_id, ended_at, end_reason)
VALUES (?, 'discord', 'discord', ?, 'deepseek/deepseek-chat', ?, 1, NULL, NULL, NULL)
""",
(sid, f"Discord chat {i}", started),
)
conn.execute(
"INSERT INTO messages (id, session_id, role, content, timestamp)"
" VALUES (?, ?, 'user', 'hello from discord', ?)",
(f"discord_msg_{i:04d}", sid, started),
)
conn.commit()
conn.close()
def test_discord_sessions_not_squeezed_out_by_cron(tmp_path):
"""Discord sessions must appear in the main pass even when cron rows are newer."""
db = tmp_path / "state.db"
_make_state_db(db, cron_count=15, discord_count=5)
with (
mock.patch("api.models.get_claude_code_sessions", return_value=[]),
mock.patch("api.models.get_last_workspace", return_value=tmp_path),
mock.patch("api.models.ensure_cron_project", return_value="cron-project-id"),
mock.patch("api.models.Session.load_metadata_only", return_value=None),
):
result = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None)
source_tags = {s["source_tag"] for s in result}
session_ids = {s["session_id"] for s in result}
discord_ids = {f"discord_{i:04d}" for i in range(5)}
assert discord_ids <= session_ids, (
"Discord sessions were squeezed out of the sidebar by cron entries. "
f"Missing: {discord_ids - session_ids}"
)
assert "discord" in source_tags
def test_cron_sessions_recovered_by_second_pass(tmp_path):
"""Cron sessions must still appear via the second pass after the main-pass exclusion."""
db = tmp_path / "state.db"
_make_state_db(db, cron_count=15, discord_count=5)
with (
mock.patch("api.models.get_claude_code_sessions", return_value=[]),
mock.patch("api.models.get_last_workspace", return_value=tmp_path),
mock.patch("api.models.ensure_cron_project", return_value="cron-project-id"),
mock.patch("api.models.Session.load_metadata_only", return_value=None),
):
result = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None)
cron_sessions = [s for s in result if s["source_tag"] == "cron"]
assert len(cron_sessions) > 0, "Cron sessions should be recovered by the second pass"
def test_webui_sidecarless_sessions_not_excluded(tmp_path):
"""WebUI sessions in state.db without JSON sidecars must remain visible.
The exclude_sources must only filter cron, not webui. A source='webui'
row with messages but no JSON sidecar file needs to appear in the sidebar
so the session_recovery path can materialize the sidecar on demand.
"""
db = tmp_path / "state.db"
conn = sqlite3.connect(str(db))
conn.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
session_source TEXT,
title TEXT,
model TEXT,
started_at REAL NOT NULL,
message_count INTEGER DEFAULT 0,
parent_session_id TEXT,
ended_at REAL,
end_reason TEXT
);
CREATE INDEX idx_sessions_started ON sessions(started_at);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT,
role TEXT,
content TEXT,
timestamp REAL
);
CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
"""
)
now = time.time()
conn.execute(
"""
INSERT INTO sessions
(id, source, session_source, title, model, started_at, message_count,
parent_session_id, ended_at, end_reason)
VALUES ('webui_orphan_001', 'webui', 'webui', 'Lost sidecar session',
'claude-sonnet-4.6', ?, 3, NULL, NULL, NULL)
""",
(now,),
)
for i in range(3):
conn.execute(
"INSERT INTO messages (id, session_id, role, content, timestamp)"
" VALUES (?, 'webui_orphan_001', 'user', 'message', ?)",
(f"webui_msg_{i}", now + i),
)
conn.commit()
conn.close()
with (
mock.patch("api.models.get_claude_code_sessions", return_value=[]),
mock.patch("api.models.get_last_workspace", return_value=tmp_path),
mock.patch("api.models.ensure_cron_project", return_value="cron-project-id"),
mock.patch("api.models.Session.load_metadata_only", return_value=None),
):
result = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None)
webui_ids = {s["session_id"] for s in result if s["source_tag"] == "webui"}
assert "webui_orphan_001" in webui_ids, (
"WebUI sidecar-less sessions must not be excluded from the main pass. "
"The exclude_sources should be ('cron',), not ('cron', 'webui')."
)