diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 222a188d..1bdaf48b 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -313,7 +313,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]: # touched standalone sessions — exactly the inverse of what a user # expects from "Show agent sessions" sorted by activity. for key in ( - 'id', 'model', 'message_count', 'actual_message_count', + 'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count', 'ended_at', 'end_reason', 'last_activity', ): if key in tip: @@ -367,6 +367,8 @@ def read_importable_agent_session_rows( # source column we cannot safely distinguish WebUI rows from agent rows. cur.execute("PRAGMA table_info(sessions)") session_cols = {row[1] for row in cur.fetchall()} + cur.execute("PRAGMA table_info(messages)") + message_cols = {row[1] for row in cur.fetchall()} if 'source' not in session_cols: log.warning( "agent session listing skipped: state.db at %s has no 'source' column " @@ -387,6 +389,11 @@ def read_importable_agent_session_rows( origin_chat_id_expr = _optional_col('origin_chat_id', session_cols) origin_user_id_expr = _optional_col('origin_user_id', session_cols) platform_expr = _optional_col('platform', session_cols) + user_message_count_expr = ( + "COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END)" + if 'role' in message_cols + else "COUNT(m.id)" + ) where_clauses = ["s.source IS NOT NULL"] params: list[str] = [] @@ -413,7 +420,7 @@ def read_importable_agent_session_rows( {ended_expr}, {end_reason_expr}, COUNT(m.id) AS actual_message_count, - COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END) AS actual_user_message_count, + {user_message_count_expr} AS actual_user_message_count, MAX(m.timestamp) AS last_activity FROM sessions s LEFT JOIN messages m ON m.session_id = s.id diff --git a/api/models.py b/api/models.py index aadb3963..6a939b4f 100644 --- a/api/models.py +++ b/api/models.py @@ -226,6 +226,12 @@ def _last_message_timestamp(messages): return None +def _message_role(message): + if not isinstance(message, dict): + return '' + return str(message.get('role', '')).strip().lower() + + def _find_top_level_json_key(text, key): """Return the byte offset of a top-level JSON object key, if present.""" depth = 0 @@ -538,11 +544,6 @@ class Session: last_message_at = _last_message_timestamp(self.messages) or self.updated_at if has_pending_user_message and self.pending_started_at: last_message_at = self.pending_started_at - - def _role(message): - if not isinstance(message, dict): - return "" - return str(message.get('role', '')).strip().lower() return { 'session_id': self.session_id, 'title': self.title, @@ -560,9 +561,6 @@ class Session: 'input_tokens': self.input_tokens, 'output_tokens': self.output_tokens, 'estimated_cost': self.estimated_cost, - 'user_message_count': sum( - 1 for message in self.messages if _role(message) == 'user' - ) if isinstance(self.messages, list) else 0, 'personality': self.personality, 'compression_anchor_visible_idx': self.compression_anchor_visible_idx, 'compression_anchor_message_key': self.compression_anchor_message_key, @@ -572,6 +570,9 @@ class Session: # Only emit 'parent_session_id' when set (the /branch fork link, #1342). # Sessions without a fork must not leak None — see test_session_lineage_metadata_api. **({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}), + 'user_message_count': sum( + 1 for message in self.messages if _message_role(message) == 'user' + ) if isinstance(self.messages, list) else 0, 'active_stream_id': self.active_stream_id, 'pending_user_message': self.pending_user_message, 'has_pending_user_message': has_pending_user_message, diff --git a/tests/test_cron_session_title.py b/tests/test_cron_session_title.py index 78b9f384..f6db3103 100644 --- a/tests/test_cron_session_title.py +++ b/tests/test_cron_session_title.py @@ -142,6 +142,16 @@ def test_non_cron_sessions_unaffected(fake_hermes_home): _make_state_db(fake_hermes_home / "state.db", [ ("cron_cd65df6fc1a8_xx", None, "cli"), ]) + # PR #1587 hides one-off default-titled CLI rows. Keep this fixture visible + # so the test remains focused on the cron-name guard rather than sidebar + # filtering. + conn = sqlite3.connect(str(fake_hermes_home / "state.db")) + conn.execute( + "INSERT INTO messages (session_id, timestamp) VALUES (?, ?)", + ("cron_cd65df6fc1a8_xx", 1700000002.0), + ) + conn.commit() + conn.close() sessions = models.get_cli_sessions()