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")