Merge pull request #3204 from nesquena/release/stage-batch56
Release stage-batch56 → v0.51.174 (CLI/gateway session usage in Insights, double-count fix)
This commit is contained in:
3 files changed
+156
No files matched your search
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.174] — 2026-05-30 — Release ET (stage-batch56 — CLI/gateway session usage in Insights)
|
||||
|
||||
### Added
|
||||
|
||||
- The Insights page now includes CLI and gateway sessions (Telegram, Discord, cron, TUI) from the Hermes agent's `state.db` in usage totals, model breakdown, and daily activity — not just WebUI-native sessions (#3189). WebUI sessions are de-duplicated so they are counted once, not double-counted against their `state.db` row.
|
||||
|
||||
## [v0.51.173] — 2026-05-30 — Release ES (stage-batch55 — Windows path/journal safety + pin-quota snapshot fix + tool-card paging anchor + sidebar dedupe + quieter tool cards)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -3539,6 +3539,63 @@ def _handle_insights(handler, parsed) -> bool:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Also include CLI sessions from Hermes state.db ─────────────────────
|
||||
try:
|
||||
from api.models import _active_state_db_path
|
||||
db_path = _active_state_db_path()
|
||||
if db_path and db_path.exists():
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, model, message_count, input_tokens, output_tokens,
|
||||
estimated_cost_usd, started_at, ended_at
|
||||
FROM sessions
|
||||
WHERE (started_at >= ? OR ended_at >= ?)
|
||||
AND COALESCE(source, '') != 'webui'
|
||||
""", (cutoff, cutoff))
|
||||
for row in cur.fetchall():
|
||||
_input = _safe_usage_int(row["input_tokens"])
|
||||
_output = _safe_usage_int(row["output_tokens"])
|
||||
_cost = _safe_cost_float(row["estimated_cost_usd"])
|
||||
_msgs = _safe_usage_int(row["message_count"])
|
||||
total_sessions += 1
|
||||
total_messages += _msgs
|
||||
total_input_tokens += _input
|
||||
total_output_tokens += _output
|
||||
total_cost += _cost
|
||||
|
||||
_model = row["model"] or "unknown"
|
||||
bucket = model_stats.setdefault(_model, {
|
||||
"sessions": 0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"cost": 0.0,
|
||||
})
|
||||
bucket["sessions"] += 1
|
||||
bucket["input_tokens"] += _input
|
||||
bucket["output_tokens"] += _output
|
||||
bucket["cost"] += _cost
|
||||
|
||||
_ts = row["started_at"] or row["ended_at"] or 0
|
||||
if _ts:
|
||||
_dt = _time.localtime(_ts)
|
||||
_day_key = _time.strftime("%Y-%m-%d", _dt)
|
||||
_daily = daily_tokens.setdefault(_day_key, {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"sessions": 0,
|
||||
"cost": 0.0,
|
||||
})
|
||||
_daily["input_tokens"] += _input
|
||||
_daily["output_tokens"] += _output
|
||||
_daily["sessions"] += 1
|
||||
_daily["cost"] += _cost
|
||||
dow_activity[_dt.tm_wday] += 1
|
||||
hod_activity[_dt.tm_hour] += 1
|
||||
except Exception:
|
||||
logger.debug("Failed to include CLI sessions in insights", exc_info=True)
|
||||
|
||||
# Build model breakdown
|
||||
total_tokens = total_input_tokens + total_output_tokens
|
||||
models_breakdown = []
|
||||
|
||||
@@ -318,3 +318,96 @@ def test_insights_mobile_models_table_has_contained_overflow():
|
||||
section_block = STYLE_CSS[section_start:section_end]
|
||||
assert 'overflow-x' in section_block, 'Mobile rule should include overflow-x handling'
|
||||
assert 'insights-model-table' in section_block or 'insights-card' in section_block
|
||||
|
||||
|
||||
# ── #3189: CLI/gateway sessions in Insights + webui double-count guard ──────
|
||||
|
||||
|
||||
def _call_insights_with_state_db(monkeypatch, tmp_path, entries, state_rows, days="7", now=None):
|
||||
"""Like _call_insights but also seeds an agent state.db with `sessions` rows
|
||||
and points _active_state_db_path at it, so the CLI second-pass is exercised."""
|
||||
import sqlite3
|
||||
import api.routes as routes
|
||||
import api.models as models
|
||||
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
(session_dir / "_index.json").write_text(json.dumps(entries), encoding="utf-8")
|
||||
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
|
||||
if now is not None:
|
||||
monkeypatch.setattr(time, "time", lambda: now)
|
||||
|
||||
db_path = tmp_path / "state.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute(
|
||||
"""CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, source TEXT, model TEXT, message_count INTEGER,
|
||||
input_tokens INTEGER, output_tokens INTEGER, estimated_cost_usd REAL,
|
||||
started_at REAL, ended_at REAL
|
||||
)"""
|
||||
)
|
||||
for r in state_rows:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, source, model, message_count, input_tokens, "
|
||||
"output_tokens, estimated_cost_usd, started_at, ended_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(r["id"], r.get("source"), r.get("model"), r.get("message_count", 0),
|
||||
r.get("input_tokens", 0), r.get("output_tokens", 0),
|
||||
r.get("estimated_cost_usd", 0.0), r.get("started_at"), r.get("ended_at")),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# _handle_insights does `from api.models import _active_state_db_path`, so patch on the module.
|
||||
monkeypatch.setattr(models, "_active_state_db_path", lambda: db_path)
|
||||
|
||||
handler = _FakeHandler()
|
||||
parsed = SimpleNamespace(query=f"days={days}")
|
||||
routes._handle_insights(handler, parsed)
|
||||
assert handler.status == 200
|
||||
return handler.json_body()
|
||||
|
||||
|
||||
def test_insights_includes_cli_and_gateway_sessions(monkeypatch, tmp_path):
|
||||
"""CLI / Telegram / Discord sessions in state.db should appear in Insights totals."""
|
||||
now = time.mktime((2026, 5, 30, 12, 0, 0, 0, 0, -1))
|
||||
webui_entries = [
|
||||
{"session_id": "w1", "updated_at": now, "created_at": now, "message_count": 2,
|
||||
"input_tokens": 100, "output_tokens": 50, "estimated_cost": 0.01, "model": "gpt-5.5"},
|
||||
]
|
||||
state_rows = [
|
||||
{"id": "cli1", "source": "cli", "model": "gpt-5.5", "message_count": 3,
|
||||
"input_tokens": 200, "output_tokens": 80, "estimated_cost_usd": 0.02,
|
||||
"started_at": now, "ended_at": now},
|
||||
{"id": "tg1", "source": "telegram", "model": "gpt-5.5", "message_count": 1,
|
||||
"input_tokens": 60, "output_tokens": 20, "estimated_cost_usd": 0.005,
|
||||
"started_at": now, "ended_at": now},
|
||||
]
|
||||
data = _call_insights_with_state_db(monkeypatch, tmp_path, webui_entries, state_rows, days="7", now=now)
|
||||
# 1 webui + 2 cli/gateway = 3 sessions counted
|
||||
assert data["total_sessions"] == 3
|
||||
# input tokens summed across all three: 100 + 200 + 60 = 360
|
||||
assert data["total_input_tokens"] == 360
|
||||
|
||||
|
||||
def test_insights_does_not_double_count_webui_state_db_rows(monkeypatch, tmp_path):
|
||||
"""WebUI sessions persist to BOTH _index.json AND state.db (source='webui').
|
||||
The CLI second-pass must exclude source='webui' so they aren't counted twice."""
|
||||
now = time.mktime((2026, 5, 30, 12, 0, 0, 0, 0, -1))
|
||||
webui_entries = [
|
||||
{"session_id": "w1", "updated_at": now, "created_at": now, "message_count": 2,
|
||||
"input_tokens": 100, "output_tokens": 50, "estimated_cost": 0.01, "model": "gpt-5.5"},
|
||||
]
|
||||
# The SAME webui session also has a state.db row (source='webui') + one real cli row.
|
||||
state_rows = [
|
||||
{"id": "w1", "source": "webui", "model": "gpt-5.5", "message_count": 2,
|
||||
"input_tokens": 100, "output_tokens": 50, "estimated_cost_usd": 0.01,
|
||||
"started_at": now, "ended_at": now},
|
||||
{"id": "cli1", "source": "cli", "model": "gpt-5.5", "message_count": 3,
|
||||
"input_tokens": 200, "output_tokens": 80, "estimated_cost_usd": 0.02,
|
||||
"started_at": now, "ended_at": now},
|
||||
]
|
||||
data = _call_insights_with_state_db(monkeypatch, tmp_path, webui_entries, state_rows, days="7", now=now)
|
||||
# webui session counted once (from _index.json), cli once = 2, NOT 3.
|
||||
assert data["total_sessions"] == 2
|
||||
# webui tokens counted once (100) + cli (200) = 300, NOT 400.
|
||||
assert data["total_input_tokens"] == 300
|
||||
Reference in new issue
Block a user