Merge pull request #7800 from nesquena/stage/0924-b
release: exp-v0.52.367 (#7798 CLI-archived sessions stay archived)
This commit is contained in:
6 files changed
+144
-7
No files matched your search
@@ -39,6 +39,13 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sessions archived in the CLI stay archived in the WebUI.** A cron, webhook, Kanban or CLI
|
||||
session archived from the CLI came back as active in the sidebar whenever it had no WebUI sidecar,
|
||||
because the projection treated a missing sidecar as "not archived". A missing sidecar now means no
|
||||
opinion, so the state.db `archived` flag applies; archiving or unarchiving in the WebUI still wins.
|
||||
In all-profiles mode, a CLI archive in another profile now refreshes the cached session list.
|
||||
(#7548, #7798 by @webtecnica)
|
||||
|
||||
- **Approval and clarify prompts send a browser notification whenever you aren't looking at them.**
|
||||
A card that surfaced through the normal prompt path never produced a notification, and the
|
||||
visibility gate muted cards in a tab that was visible but unfocused. Now every approval or clarify
|
||||
|
||||
@@ -576,7 +576,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
|
||||
# expects from "Show agent sessions" sorted by activity.
|
||||
for key in (
|
||||
'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count',
|
||||
'ended_at', 'end_reason', 'last_activity',
|
||||
'ended_at', 'end_reason', 'last_activity', 'archived',
|
||||
):
|
||||
if key in tip:
|
||||
merged[key] = tip[key]
|
||||
@@ -683,6 +683,7 @@ 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)
|
||||
archived_expr = _optional_col('archived', session_cols)
|
||||
# Older/minimal state.db schemas can have NO ``messages`` table at all,
|
||||
# or a ``messages`` table without a ``session_id`` / ``timestamp`` column.
|
||||
# The projection SQL below joins ``messages`` and aggregates
|
||||
@@ -793,6 +794,7 @@ def read_importable_agent_session_rows(
|
||||
{parent_expr},
|
||||
{ended_expr},
|
||||
{end_reason_expr},
|
||||
{archived_expr},
|
||||
{actual_count_expr} AS actual_message_count,
|
||||
{user_message_count_expr} AS actual_user_message_count,
|
||||
{last_activity_expr} AS last_activity
|
||||
|
||||
+17
-5
@@ -8073,7 +8073,7 @@ def _state_projection_sidecar_metadata(sid: str) -> dict:
|
||||
stops being true (metadata moves to another store), this gate would short-
|
||||
circuit before the real source — update both together.
|
||||
"""
|
||||
default = {"title": None, "archived": False}
|
||||
default = {"title": None, "archived": None}
|
||||
if not is_safe_session_id(sid):
|
||||
return dict(default)
|
||||
p = SESSION_DIR / f'{sid}.json'
|
||||
@@ -8270,7 +8270,10 @@ def _load_cli_sessions_uncached(
|
||||
_sidecar_meta = _state_projection_sidecar_metadata(sid)
|
||||
if _sidecar_meta.get('title'):
|
||||
_title = _sidecar_meta['title']
|
||||
_archived = bool(_sidecar_meta.get('archived'))
|
||||
if _sidecar_meta.get('archived') is not None:
|
||||
_archived = bool(_sidecar_meta['archived'])
|
||||
else:
|
||||
_archived = bool(row.get('archived'))
|
||||
_display_title = _title or f'{_source.title()} Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
@@ -8342,7 +8345,10 @@ def _load_cli_sessions_uncached(
|
||||
_sidecar_meta = _state_projection_sidecar_metadata(sid)
|
||||
if _sidecar_meta.get('title'):
|
||||
_title = _sidecar_meta['title']
|
||||
_archived = bool(_sidecar_meta.get('archived'))
|
||||
if _sidecar_meta.get('archived') is not None:
|
||||
_archived = bool(_sidecar_meta['archived'])
|
||||
else:
|
||||
_archived = bool(row.get('archived'))
|
||||
_display_title = _title or 'Cron Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
@@ -8408,7 +8414,10 @@ def _load_cli_sessions_uncached(
|
||||
_sidecar_meta = _state_projection_sidecar_metadata(sid)
|
||||
if _sidecar_meta.get('title'):
|
||||
_title = _sidecar_meta['title']
|
||||
_archived = bool(_sidecar_meta.get('archived'))
|
||||
if _sidecar_meta.get('archived') is not None:
|
||||
_archived = bool(_sidecar_meta['archived'])
|
||||
else:
|
||||
_archived = bool(row.get('archived'))
|
||||
_display_title = _title or 'Webhook Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
@@ -8473,7 +8482,10 @@ def _load_cli_sessions_uncached(
|
||||
_sidecar_meta = _state_projection_sidecar_metadata(sid)
|
||||
if _sidecar_meta.get('title'):
|
||||
_title = _sidecar_meta['title']
|
||||
_archived = bool(_sidecar_meta.get('archived'))
|
||||
if _sidecar_meta.get('archived') is not None:
|
||||
_archived = bool(_sidecar_meta['archived'])
|
||||
else:
|
||||
_archived = bool(row.get('archived'))
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': _title or 'Kanban Session',
|
||||
|
||||
@@ -606,6 +606,22 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple
|
||||
session_index_path = _session_list_cache_session_dir() / "_index.json"
|
||||
except Exception:
|
||||
session_index_path = None
|
||||
all_profiles_state_stamp = None
|
||||
if _cache_all_profiles:
|
||||
try:
|
||||
from api.models import _all_profiles_cli_contexts
|
||||
contexts, _ = _all_profiles_cli_contexts()
|
||||
all_profiles_state_stamp = tuple(
|
||||
(
|
||||
_session_list_cache_path_stamp(ctx_db),
|
||||
_session_list_cache_path_stamp(ctx_db.with_name(f"{ctx_db.name}-wal") if ctx_db else None),
|
||||
_session_list_cache_state_db_fingerprint(ctx_db),
|
||||
)
|
||||
for _home, ctx_db, _prof in contexts
|
||||
)
|
||||
except Exception:
|
||||
all_profiles_state_stamp = None
|
||||
|
||||
return (
|
||||
_session_list_cache_path_stamp(state_db_path),
|
||||
_session_list_cache_path_stamp(state_db_wal_path),
|
||||
@@ -617,6 +633,7 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple
|
||||
# frame size), so without this a freshly-committed CLI/gateway session
|
||||
# could be served stale for the cache TTL. Mirrors the models-layer fix.
|
||||
_session_list_cache_state_db_fingerprint(state_db_path),
|
||||
all_profiles_state_stamp,
|
||||
swv,
|
||||
)
|
||||
|
||||
|
||||
@@ -199,7 +199,9 @@ def test_missing_sidecar_returns_default_without_caching_growth(tmp_path):
|
||||
session_dir.mkdir()
|
||||
with mock.patch("api.models.SESSION_DIR", session_dir):
|
||||
meta = models._state_projection_sidecar_metadata("cron_nope_999")
|
||||
assert meta == {"title": None, "archived": False}
|
||||
# No sidecar means no opinion on archived (None), so the projection falls back
|
||||
# to the state.db row's archived flag (#7548) instead of forcing it to False.
|
||||
assert meta == {"title": None, "archived": None}
|
||||
# No file → nothing cached (so the cache can't be poisoned by absent files).
|
||||
assert len(models._SIDECAR_METADATA_CACHE) == 0
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Regression test for issue #7548:
|
||||
CLI-archived cron/webhook/kanban sessions reappear from state.db projection (no sidecar case).
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from api.agent_sessions import read_importable_agent_session_rows
|
||||
from api.models import get_cli_sessions, _state_projection_sidecar_metadata
|
||||
|
||||
|
||||
def _create_state_db(path: Path, rows: list[tuple]):
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
model TEXT,
|
||||
message_count INTEGER,
|
||||
started_at REAL,
|
||||
source TEXT,
|
||||
session_source TEXT,
|
||||
archived INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
role TEXT,
|
||||
timestamp REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
for r in rows:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, title, model, message_count, started_at, source, session_source, archived) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
r,
|
||||
)
|
||||
# insert a dummy user message so actual_message_count > 0
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, timestamp) VALUES (?, 'user', ?)",
|
||||
(r[0], r[4]),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_cli_archived_session_without_sidecar_retains_archived_state(tmp_path, monkeypatch):
|
||||
"""When a session is marked archived in state.db and has no WebUI sidecar,
|
||||
the projection must preserve archived=True and not reset it to False."""
|
||||
db_path = tmp_path / "state.db"
|
||||
session_rows = [
|
||||
("cron-active-1", "Cron Active", "model", 1, 1000.0, "cron", "cron", 0),
|
||||
("cron-archived-1", "Cron Archived", "model", 1, 1000.0, "cron", "cron", 1),
|
||||
]
|
||||
_create_state_db(db_path, session_rows)
|
||||
|
||||
# Test read_importable_agent_session_rows directly
|
||||
projected = read_importable_agent_session_rows(
|
||||
db_path,
|
||||
limit=200,
|
||||
exclude_sources=None,
|
||||
include_sources=("cron",),
|
||||
)
|
||||
by_id = {r["id"]: r for r in projected}
|
||||
assert "cron-active-1" in by_id
|
||||
assert "cron-archived-1" in by_id
|
||||
assert bool(by_id["cron-active-1"].get("archived")) is False
|
||||
assert bool(by_id["cron-archived-1"].get("archived")) is True
|
||||
|
||||
# Test _state_projection_sidecar_metadata returns archived: None when no sidecar exists
|
||||
meta = _state_projection_sidecar_metadata("cron-archived-1")
|
||||
assert meta.get("archived") is None
|
||||
|
||||
# Test get_cli_sessions() projection
|
||||
from api.models import clear_cli_sessions_cache
|
||||
clear_cli_sessions_cache()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
try:
|
||||
import api.profiles # noqa: F401 # makes the monkeypatch target below importable
|
||||
monkeypatch.setattr("api.profiles.get_active_hermes_home", lambda: str(tmp_path))
|
||||
except Exception:
|
||||
pass
|
||||
monkeypatch.setattr("api.models._active_state_db_path", lambda: db_path)
|
||||
monkeypatch.setattr("api.models.SESSION_DIR", tmp_path / "webui_sessions")
|
||||
(tmp_path / "webui_sessions").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cli_sessions = get_cli_sessions(source_filter="cron")
|
||||
cli_by_id = {s["session_id"]: s for s in cli_sessions}
|
||||
assert "cron-active-1" in cli_by_id
|
||||
assert "cron-archived-1" in cli_by_id
|
||||
assert cli_by_id["cron-active-1"]["archived"] is False
|
||||
assert cli_by_id["cron-archived-1"]["archived"] is True
|
||||
Reference in new issue
Block a user