From 23c03ced75031e85c418f1660947c03e92f9e205 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:05:41 -0700 Subject: [PATCH] fix(session-db): enrich NULL session metadata via upsert instead of INSERT OR IGNORE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway's get_or_create_session() creates a bare session row (source + user_id) before the agent exists. The agent's later create_session() carries the real model/model_config/system_prompt, but _insert_session_row used INSERT OR IGNORE — silently dropping that enrichment. Gateway sessions were left with NULL model and NULL billing metadata. Switch to INSERT ... ON CONFLICT(id) DO UPDATE with COALESCE so NULL columns get backfilled while values an earlier writer already set are never overwritten (a later bare write with source='unknown' can't clobber a real source/model). Credit: original report and fix direction by @LucidPaths (#5048). --- hermes_state.py | 27 ++++++++++++++++++++++++--- tests/test_hermes_state.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index d8e4c896124..e69dd4b02c9 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1486,14 +1486,35 @@ class SessionDB: parent_session_id: str = None, cwd: str = None, ) -> None: - """Shared INSERT OR IGNORE for session rows.""" + """Insert a session row, enriching NULL metadata on conflict. + + The gateway's ``get_or_create_session`` creates a bare row (source + + user_id) *before* the agent exists; the agent's later + ``create_session`` then carries the real ``model`` / ``model_config`` / + ``system_prompt``. A plain ``INSERT OR IGNORE`` silently dropped that + enrichment, leaving gateway sessions with NULL model/billing metadata. + The ``ON CONFLICT`` upsert backfills those fields via ``COALESCE`` — + only filling columns that are still NULL, never overwriting values an + earlier writer already set (so a later bare call with source="unknown" + can't clobber a real source/model). + """ def _do(conn): conn.execute( - """INSERT OR IGNORE INTO sessions ( + """INSERT INTO sessions ( id, source, user_id, session_key, chat_id, chat_type, thread_id, model, model_config, system_prompt, parent_session_id, cwd, started_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + model = COALESCE(sessions.model, excluded.model), + model_config = COALESCE(sessions.model_config, excluded.model_config), + system_prompt = COALESCE(sessions.system_prompt, excluded.system_prompt), + session_key = COALESCE(sessions.session_key, excluded.session_key), + chat_id = COALESCE(sessions.chat_id, excluded.chat_id), + chat_type = COALESCE(sessions.chat_type, excluded.chat_type), + thread_id = COALESCE(sessions.thread_id, excluded.thread_id), + parent_session_id = COALESCE(sessions.parent_session_id, excluded.parent_session_id), + cwd = COALESCE(sessions.cwd, excluded.cwd)""", ( session_id, source, diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 6b623b2ba39..5076182166a 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -96,6 +96,37 @@ class TestSessionLifecycle: def test_get_nonexistent_session(self, db): assert db.get_session("nonexistent") is None + def test_create_session_enriches_null_metadata_on_conflict(self, db): + """Gateway creates a bare row first; the agent's later create_session + must backfill model/model_config/system_prompt without clobbering the + gateway's source/user_id/chat_id. Regression for NULL gateway metadata + (sessions with NULL billing_provider/model).""" + # Gateway bare row (source + user_id only), before the agent exists. + db.create_session("s1", source="telegram", user_id="u1", chat_id="c1") + bare = db.get_session("s1") + assert bare["model"] is None + # Agent enriches — passes source="cli" but real metadata. + db.create_session( + "s1", source="cli", model="claude-opus-4-6", + model_config={"max_iterations": 90}, system_prompt="SYS", + ) + enriched = db.get_session("s1") + assert enriched["model"] == "claude-opus-4-6" + assert enriched["system_prompt"] == "SYS" + # Gateway-owned fields preserved (NOT clobbered by source="cli"). + assert enriched["source"] == "telegram" + assert enriched["user_id"] == "u1" + assert enriched["chat_id"] == "c1" + + def test_create_session_does_not_overwrite_existing_metadata(self, db): + """A later bare write (source='unknown', model=...) must not overwrite + a model/source an earlier writer already set.""" + db.create_session("s1", source="cli", model="real-model") + db.create_session("s1", source="unknown", model="should-not-win") + session = db.get_session("s1") + assert session["model"] == "real-model" + assert session["source"] == "cli" + def test_update_session_cwd_persists_git_branch(self, db): db.create_session(session_id="s1", source="cli") db.update_session_cwd("s1", "/work/repo", git_branch="pets-feature")