2026-06-21 23:50:32 +00:00
|
|
|
"""Regression coverage for orphaned imported sidecar pruning.
|
|
|
|
|
|
|
|
|
|
#3238: WebUI does not sync when CLI sessions are deleted outside WebUI.
|
|
|
|
|
#4591: API-server sidecars are read-only and accumulated indefinitely after
|
|
|
|
|
their backing state.db row was deleted.
|
Release v0.51.222 — Release GP (stage-p4 — backend bugfix batch: title language drift #3293 + orphaned CLI sidecar prune #3238 + pin-quota lineage #3288) (#3452)
* fix: reject cross-script drifted auto-generated session titles (#3293)
The title-language mismatch guard only knew two states: German (de) or empty,
and _title_language_mismatch early-returned False whenever the user start
wasn't German. So an English conversation whose LLM-generated title came back
in Chinese / Spanish / Russian sailed through and persisted with
llm_title_generated=true. The German case was the only one covered because
that's the one prior report it was built for.
Generalize from a German-specific binary to a language-agnostic cross-script
check. Add _script_counts() + _dominant_script() (cheap, dependency-free
Unicode-block classification: latin / cjk / cyrillic / arabic / hebrew / greek
/ devanagari). _title_language_mismatch now rejects a title that introduces a
substantial amount (>=35% of alphabetic chars, min 2) of a script different
from the conversation start's dominant script — so short titles that embed a
borrowed Latin technical term still trip, while an English title with a single
foreign place-name does not. The legacy German->English same-script heuristic
is preserved verbatim.
Kept api/streaming.py ASCII-only (the test_title_generation_source_has_no_cjk_
literals guard) — all CJK examples live in the test file, not the source.
Closes #3293
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
* fix: prune orphaned imported-CLI sidecars from the WebUI sidebar (#3238)
When a CLI/agent session is opened in WebUI it gets a WebUI-owned sidecar
(webui/sessions/<id>.json + _index.json row) so it can render and reopen;
all_sessions() then returns it independently of the agent state.db. If the
user later deletes that session from the CLI / local Hermes storage, nothing
pruned the sidecar — the merge loop only overlays CLI metadata when a matching
state.db row exists and otherwise continues, so the stale row lingered in the
sidebar indefinitely (there is no WebUI delete affordance for CLI rows).
Add api.models.agent_session_row_exists(): an exact, uncapped existence probe
against the state.db sessions table. The sidebar merge loop now drops a row
that is_cli_session_row + not WebUI-native + absent from cli_by_id + whose
state.db row is genuinely gone, and calls prune_session_from_index() so
_index.json self-heals.
The state.db probe is deliberate: get_cli_sessions() caps at
CLI_VISIBLE_SESSION_LIMIT (20), so a still-existing session can fall out of
that window and look deleted — pruning on cli_by_id absence alone would delete
live sessions. WebUI-native rows with a CLI ancestor are never pruned, and any
probe error degrades to keep-the-row so a transient failure can't lose data.
Closes #3238
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
* fix: count pin quota by visible session lineage
* docs(changelog): v0.51.222 — backend bugfix batch (#3293 title drift, #3238 sidecar prune, #3288 pin lineage)
* fix(pins): forks count as own pin lineage, not collapsed to parent (#3288 Codex follow-up)
Codex review of the batch found a pin-limit UNDERCOUNT: _session_row_lineage_root_id
followed any parent_session_id to the root, but /api/session/branch creates
independent visible fork sessions that also carry parent_session_id (session_source=
'fork'). Two pinned forks of the same parent collapsed to one quota lineage, letting
a user exceed pinned_sessions_limit with no 400. Fix: a fork returns its own id as
its lineage root (it's a separately-visible session); only compression/continuation
rows still collapse to a shared root. Adds a regression test with two pinned forks +
the parent counting as three distinct lineages, and confirms the existing
pre-compression-snapshot collapse case still passes.
* test(pins): update #2508/#2821 source-match tests for #3288 lineage rename
#3288 replaced the raw-session-id pin counter (pinned_ids set) with a
visible-lineage counter (pinned_lineage_ids via _visible_pinned_lineage_ids over
persisted_rows/candidate_rows). Two pre-existing source-string-matching tests
asserted the OLD implementation literals (pinned_ids = {, _session_field(existing,
session_id...), len(pinned_ids) >=). Updated both to assert the new mechanism while
preserving the invariants they actually guard: snapshot computed BEFORE LOCK (no
all_sessions()-inside-LOCK deadlock), quota filtering via the shared
_session_counts_toward_pin_quota helper, and the limit/400 guard. Behaviour
unchanged; these were implementation-detail assertions, not behaviour tests.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
Co-authored-by: Andy Kang <andrewkang.kr@gmail.com>
2026-06-03 00:35:18 +00:00
|
|
|
|
|
|
|
|
When a CLI/agent session is clicked in the WebUI sidebar it gets a WebUI-owned
|
|
|
|
|
sidecar (`webui/sessions/<id>.json` + an `_index.json` row) so it can render and
|
|
|
|
|
be reopened. From then on `all_sessions()` returns it independently of the agent
|
|
|
|
|
`state.db`. If the user later deletes that session from the CLI / local Hermes
|
|
|
|
|
storage, nothing prunes the orphaned sidecar, so the stale row lingers in the
|
|
|
|
|
sidebar forever — there is no WebUI delete affordance for CLI rows.
|
|
|
|
|
|
2026-06-21 23:50:32 +00:00
|
|
|
The fix probes `state.db` directly via `agent_session_rows_existing()` (an
|
|
|
|
|
exact, uncapped existence check) and prunes the sidecar only when the backing
|
|
|
|
|
row is genuinely gone. It must NOT rely on the session's presence in
|
Release v0.51.222 — Release GP (stage-p4 — backend bugfix batch: title language drift #3293 + orphaned CLI sidecar prune #3238 + pin-quota lineage #3288) (#3452)
* fix: reject cross-script drifted auto-generated session titles (#3293)
The title-language mismatch guard only knew two states: German (de) or empty,
and _title_language_mismatch early-returned False whenever the user start
wasn't German. So an English conversation whose LLM-generated title came back
in Chinese / Spanish / Russian sailed through and persisted with
llm_title_generated=true. The German case was the only one covered because
that's the one prior report it was built for.
Generalize from a German-specific binary to a language-agnostic cross-script
check. Add _script_counts() + _dominant_script() (cheap, dependency-free
Unicode-block classification: latin / cjk / cyrillic / arabic / hebrew / greek
/ devanagari). _title_language_mismatch now rejects a title that introduces a
substantial amount (>=35% of alphabetic chars, min 2) of a script different
from the conversation start's dominant script — so short titles that embed a
borrowed Latin technical term still trip, while an English title with a single
foreign place-name does not. The legacy German->English same-script heuristic
is preserved verbatim.
Kept api/streaming.py ASCII-only (the test_title_generation_source_has_no_cjk_
literals guard) — all CJK examples live in the test file, not the source.
Closes #3293
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
* fix: prune orphaned imported-CLI sidecars from the WebUI sidebar (#3238)
When a CLI/agent session is opened in WebUI it gets a WebUI-owned sidecar
(webui/sessions/<id>.json + _index.json row) so it can render and reopen;
all_sessions() then returns it independently of the agent state.db. If the
user later deletes that session from the CLI / local Hermes storage, nothing
pruned the sidecar — the merge loop only overlays CLI metadata when a matching
state.db row exists and otherwise continues, so the stale row lingered in the
sidebar indefinitely (there is no WebUI delete affordance for CLI rows).
Add api.models.agent_session_row_exists(): an exact, uncapped existence probe
against the state.db sessions table. The sidebar merge loop now drops a row
that is_cli_session_row + not WebUI-native + absent from cli_by_id + whose
state.db row is genuinely gone, and calls prune_session_from_index() so
_index.json self-heals.
The state.db probe is deliberate: get_cli_sessions() caps at
CLI_VISIBLE_SESSION_LIMIT (20), so a still-existing session can fall out of
that window and look deleted — pruning on cli_by_id absence alone would delete
live sessions. WebUI-native rows with a CLI ancestor are never pruned, and any
probe error degrades to keep-the-row so a transient failure can't lose data.
Closes #3238
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
* fix: count pin quota by visible session lineage
* docs(changelog): v0.51.222 — backend bugfix batch (#3293 title drift, #3238 sidecar prune, #3288 pin lineage)
* fix(pins): forks count as own pin lineage, not collapsed to parent (#3288 Codex follow-up)
Codex review of the batch found a pin-limit UNDERCOUNT: _session_row_lineage_root_id
followed any parent_session_id to the root, but /api/session/branch creates
independent visible fork sessions that also carry parent_session_id (session_source=
'fork'). Two pinned forks of the same parent collapsed to one quota lineage, letting
a user exceed pinned_sessions_limit with no 400. Fix: a fork returns its own id as
its lineage root (it's a separately-visible session); only compression/continuation
rows still collapse to a shared root. Adds a regression test with two pinned forks +
the parent counting as three distinct lineages, and confirms the existing
pre-compression-snapshot collapse case still passes.
* test(pins): update #2508/#2821 source-match tests for #3288 lineage rename
#3288 replaced the raw-session-id pin counter (pinned_ids set) with a
visible-lineage counter (pinned_lineage_ids via _visible_pinned_lineage_ids over
persisted_rows/candidate_rows). Two pre-existing source-string-matching tests
asserted the OLD implementation literals (pinned_ids = {, _session_field(existing,
session_id...), len(pinned_ids) >=). Updated both to assert the new mechanism while
preserving the invariants they actually guard: snapshot computed BEFORE LOCK (no
all_sessions()-inside-LOCK deadlock), quota filtering via the shared
_session_counts_toward_pin_quota helper, and the limit/400 guard. Behaviour
unchanged; these were implementation-detail assertions, not behaviour tests.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
Co-authored-by: Andy Kang <andrewkang.kr@gmail.com>
2026-06-03 00:35:18 +00:00
|
|
|
`get_cli_sessions()`, which caps at `CLI_VISIBLE_SESSION_LIMIT` (20) — an
|
|
|
|
|
existing session can fall out of that window and look deleted.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import sqlite3
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
|
if str(REPO) not in sys.path:
|
|
|
|
|
sys.path.insert(0, str(REPO))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_state_db(path: Path, session_ids):
|
|
|
|
|
"""Create a minimal agent state.db with a `sessions` table + given ids."""
|
|
|
|
|
conn = sqlite3.connect(str(path))
|
|
|
|
|
try:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
|
|
|
|
|
"started_at REAL, last_activity REAL)"
|
|
|
|
|
)
|
|
|
|
|
for sid in session_ids:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO sessions (id, source, started_at, last_activity) "
|
|
|
|
|
"VALUES (?, 'cli', 0, 0)",
|
|
|
|
|
(sid,),
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_session_row_exists_true_for_present_row(tmp_path, monkeypatch):
|
|
|
|
|
from api import models
|
|
|
|
|
|
|
|
|
|
home = tmp_path / "home"
|
|
|
|
|
home.mkdir()
|
|
|
|
|
_make_state_db(home / "state.db", ["sess-present"])
|
|
|
|
|
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
|
|
|
|
|
|
|
|
|
|
assert models.agent_session_row_exists("sess-present") is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_session_row_exists_false_for_deleted_row(tmp_path, monkeypatch):
|
|
|
|
|
"""The core fix: a session id that is NOT in state.db is reported gone."""
|
|
|
|
|
from api import models
|
|
|
|
|
|
|
|
|
|
home = tmp_path / "home"
|
|
|
|
|
home.mkdir()
|
|
|
|
|
_make_state_db(home / "state.db", ["other-session"])
|
|
|
|
|
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
|
|
|
|
|
|
|
|
|
|
assert models.agent_session_row_exists("sess-deleted") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_session_row_exists_safe_true_when_db_missing(tmp_path, monkeypatch):
|
|
|
|
|
"""No agent DB on this instance -> never claim a row is gone (no data loss)."""
|
|
|
|
|
from api import models
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
models, "_active_state_db_path", lambda: tmp_path / "nope" / "state.db"
|
|
|
|
|
)
|
|
|
|
|
assert models.agent_session_row_exists("anything") is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_session_row_exists_empty_id_is_false(tmp_path, monkeypatch):
|
|
|
|
|
from api import models
|
|
|
|
|
|
|
|
|
|
home = tmp_path / "home"
|
|
|
|
|
home.mkdir()
|
|
|
|
|
_make_state_db(home / "state.db", ["x"])
|
|
|
|
|
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
|
|
|
|
|
assert models.agent_session_row_exists("") is False
|
|
|
|
|
assert models.agent_session_row_exists(None) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_session_row_exists_handles_missing_sessions_table(tmp_path, monkeypatch):
|
|
|
|
|
"""A state.db without a `sessions` table degrades to safe-True."""
|
|
|
|
|
from api import models
|
|
|
|
|
|
|
|
|
|
home = tmp_path / "home"
|
|
|
|
|
home.mkdir()
|
|
|
|
|
db = home / "state.db"
|
|
|
|
|
conn = sqlite3.connect(str(db))
|
|
|
|
|
conn.execute("CREATE TABLE unrelated (x TEXT)")
|
|
|
|
|
conn.commit()
|
|
|
|
|
conn.close()
|
|
|
|
|
monkeypatch.setattr(models, "_active_state_db_path", lambda: db)
|
|
|
|
|
assert models.agent_session_row_exists("whatever") is True
|
|
|
|
|
|
|
|
|
|
|
2026-06-05 17:44:02 +00:00
|
|
|
def test_agent_session_rows_existing_returns_present_subset(tmp_path, monkeypatch):
|
|
|
|
|
from api import models
|
|
|
|
|
|
|
|
|
|
home = tmp_path / "home"
|
|
|
|
|
home.mkdir()
|
|
|
|
|
_make_state_db(home / "state.db", ["sess-a", "sess-b"])
|
|
|
|
|
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
|
|
|
|
|
|
|
|
|
|
existing = models.agent_session_rows_existing(
|
|
|
|
|
["sess-a", "sess-b", "sess-missing", "", None]
|
|
|
|
|
)
|
|
|
|
|
assert existing == frozenset({"sess-a", "sess-b"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_session_rows_existing_safe_when_db_missing(tmp_path, monkeypatch):
|
|
|
|
|
from api import models
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
models, "_active_state_db_path", lambda: tmp_path / "nope" / "state.db"
|
|
|
|
|
)
|
|
|
|
|
wanted = ["orphan-a", "orphan-b"]
|
|
|
|
|
assert models.agent_session_rows_existing(wanted) == frozenset(wanted)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_session_rows_existing_batches_over_500_ids(tmp_path, monkeypatch):
|
|
|
|
|
from api import models
|
|
|
|
|
|
|
|
|
|
home = tmp_path / "home"
|
|
|
|
|
home.mkdir()
|
|
|
|
|
ids = [f"sess-{i:04d}" for i in range(600)]
|
|
|
|
|
_make_state_db(home / "state.db", ids[:300])
|
|
|
|
|
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
|
|
|
|
|
|
|
|
|
|
existing = models.agent_session_rows_existing(ids)
|
|
|
|
|
assert existing == frozenset(ids[:300])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_session_rows_existing_normalizes_whitespace_in_probe_ids(tmp_path, monkeypatch):
|
|
|
|
|
from api import models
|
|
|
|
|
|
|
|
|
|
home = tmp_path / "home"
|
|
|
|
|
home.mkdir()
|
|
|
|
|
_make_state_db(home / "state.db", ["cli-padded"])
|
|
|
|
|
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
|
|
|
|
|
|
|
|
|
|
existing = models.agent_session_rows_existing([" cli-padded "])
|
|
|
|
|
assert existing == frozenset({"cli-padded"})
|
|
|
|
|
|
|
|
|
|
|
Release v0.51.222 — Release GP (stage-p4 — backend bugfix batch: title language drift #3293 + orphaned CLI sidecar prune #3238 + pin-quota lineage #3288) (#3452)
* fix: reject cross-script drifted auto-generated session titles (#3293)
The title-language mismatch guard only knew two states: German (de) or empty,
and _title_language_mismatch early-returned False whenever the user start
wasn't German. So an English conversation whose LLM-generated title came back
in Chinese / Spanish / Russian sailed through and persisted with
llm_title_generated=true. The German case was the only one covered because
that's the one prior report it was built for.
Generalize from a German-specific binary to a language-agnostic cross-script
check. Add _script_counts() + _dominant_script() (cheap, dependency-free
Unicode-block classification: latin / cjk / cyrillic / arabic / hebrew / greek
/ devanagari). _title_language_mismatch now rejects a title that introduces a
substantial amount (>=35% of alphabetic chars, min 2) of a script different
from the conversation start's dominant script — so short titles that embed a
borrowed Latin technical term still trip, while an English title with a single
foreign place-name does not. The legacy German->English same-script heuristic
is preserved verbatim.
Kept api/streaming.py ASCII-only (the test_title_generation_source_has_no_cjk_
literals guard) — all CJK examples live in the test file, not the source.
Closes #3293
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
* fix: prune orphaned imported-CLI sidecars from the WebUI sidebar (#3238)
When a CLI/agent session is opened in WebUI it gets a WebUI-owned sidecar
(webui/sessions/<id>.json + _index.json row) so it can render and reopen;
all_sessions() then returns it independently of the agent state.db. If the
user later deletes that session from the CLI / local Hermes storage, nothing
pruned the sidecar — the merge loop only overlays CLI metadata when a matching
state.db row exists and otherwise continues, so the stale row lingered in the
sidebar indefinitely (there is no WebUI delete affordance for CLI rows).
Add api.models.agent_session_row_exists(): an exact, uncapped existence probe
against the state.db sessions table. The sidebar merge loop now drops a row
that is_cli_session_row + not WebUI-native + absent from cli_by_id + whose
state.db row is genuinely gone, and calls prune_session_from_index() so
_index.json self-heals.
The state.db probe is deliberate: get_cli_sessions() caps at
CLI_VISIBLE_SESSION_LIMIT (20), so a still-existing session can fall out of
that window and look deleted — pruning on cli_by_id absence alone would delete
live sessions. WebUI-native rows with a CLI ancestor are never pruned, and any
probe error degrades to keep-the-row so a transient failure can't lose data.
Closes #3238
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
* fix: count pin quota by visible session lineage
* docs(changelog): v0.51.222 — backend bugfix batch (#3293 title drift, #3238 sidecar prune, #3288 pin lineage)
* fix(pins): forks count as own pin lineage, not collapsed to parent (#3288 Codex follow-up)
Codex review of the batch found a pin-limit UNDERCOUNT: _session_row_lineage_root_id
followed any parent_session_id to the root, but /api/session/branch creates
independent visible fork sessions that also carry parent_session_id (session_source=
'fork'). Two pinned forks of the same parent collapsed to one quota lineage, letting
a user exceed pinned_sessions_limit with no 400. Fix: a fork returns its own id as
its lineage root (it's a separately-visible session); only compression/continuation
rows still collapse to a shared root. Adds a regression test with two pinned forks +
the parent counting as three distinct lineages, and confirms the existing
pre-compression-snapshot collapse case still passes.
* test(pins): update #2508/#2821 source-match tests for #3288 lineage rename
#3288 replaced the raw-session-id pin counter (pinned_ids set) with a
visible-lineage counter (pinned_lineage_ids via _visible_pinned_lineage_ids over
persisted_rows/candidate_rows). Two pre-existing source-string-matching tests
asserted the OLD implementation literals (pinned_ids = {, _session_field(existing,
session_id...), len(pinned_ids) >=). Updated both to assert the new mechanism while
preserving the invariants they actually guard: snapshot computed BEFORE LOCK (no
all_sessions()-inside-LOCK deadlock), quota filtering via the shared
_session_counts_toward_pin_quota helper, and the limit/400 guard. Behaviour
unchanged; these were implementation-detail assertions, not behaviour tests.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
Co-authored-by: Andy Kang <andrewkang.kr@gmail.com>
2026-06-03 00:35:18 +00:00
|
|
|
# ── Orphan-prune decision predicate (mirrors the sidebar merge-loop guard) ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_orphaned_cli_sidecar(row, cli_by_id, exists_fn):
|
|
|
|
|
"""Replicates the routes.py merge-loop predicate so the decision logic is
|
|
|
|
|
covered without standing up the full HTTP sidebar endpoint."""
|
|
|
|
|
from api.agent_sessions import is_cli_session_row
|
|
|
|
|
from api.routes import _session_source_is_webui
|
|
|
|
|
|
|
|
|
|
sid = row.get("session_id")
|
|
|
|
|
return bool(
|
|
|
|
|
sid
|
|
|
|
|
and is_cli_session_row(row)
|
|
|
|
|
and not _session_source_is_webui(row)
|
|
|
|
|
and sid not in cli_by_id
|
|
|
|
|
and not exists_fn(sid)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_orphaned_imported_cli_sidecar_is_pruned():
|
|
|
|
|
"""import-sourced CLI row, not in cli_by_id, backing state.db row gone."""
|
|
|
|
|
row = {"session_id": "cli-orphan", "source_tag": "cli", "is_cli_session": True}
|
|
|
|
|
assert _is_orphaned_cli_sidecar(row, {}, exists_fn=lambda sid: False) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_native_webui_session_with_cli_ancestor_is_never_pruned():
|
|
|
|
|
"""Regression guard: a WebUI-native row must survive even if absent from
|
|
|
|
|
cli_by_id and its (ancestor) id isn't in state.db."""
|
|
|
|
|
row = {
|
|
|
|
|
"session_id": "webui-native",
|
|
|
|
|
"source_tag": "webui",
|
|
|
|
|
"session_source": "webui",
|
|
|
|
|
"is_cli_session": False,
|
|
|
|
|
}
|
|
|
|
|
assert _is_orphaned_cli_sidecar(row, {}, exists_fn=lambda sid: False) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_row_still_backed_in_state_db_is_not_pruned():
|
|
|
|
|
"""Falls out of the recent-20 window (absent from cli_by_id) BUT still exists
|
|
|
|
|
in state.db -> must NOT be pruned. This is the cap-window false-positive the
|
|
|
|
|
direct state.db probe defends against."""
|
|
|
|
|
row = {"session_id": "cli-old", "source_tag": "cli", "is_cli_session": True}
|
|
|
|
|
assert _is_orphaned_cli_sidecar(row, {}, exists_fn=lambda sid: True) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_row_present_in_cli_by_id_is_not_pruned():
|
|
|
|
|
"""Still in the live CLI list -> obviously not orphaned."""
|
|
|
|
|
row = {"session_id": "cli-live", "source_tag": "cli", "is_cli_session": True}
|
|
|
|
|
cli_by_id = {"cli-live": {"session_id": "cli-live"}}
|
|
|
|
|
assert _is_orphaned_cli_sidecar(row, cli_by_id, exists_fn=lambda sid: False) is False
|
2026-06-21 23:50:32 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _payload_for_rows(monkeypatch, rows, existing_ids):
|
|
|
|
|
import api.routes as routes
|
|
|
|
|
|
|
|
|
|
pruned = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(routes, "all_sessions", lambda diag=None: list(rows))
|
|
|
|
|
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [])
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
routes,
|
|
|
|
|
"_reconcile_stale_stream_state_for_session_rows",
|
|
|
|
|
lambda _sessions: False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
routes,
|
|
|
|
|
"agent_session_rows_existing",
|
|
|
|
|
lambda ids, profile=None: frozenset(existing_ids),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(routes, "prune_session_from_index", lambda sid: pruned.append(sid))
|
|
|
|
|
|
|
|
|
|
payload = routes._build_session_list_cache_payload(
|
|
|
|
|
active_profile="default",
|
|
|
|
|
all_profiles=False,
|
|
|
|
|
show_cli_sessions=True,
|
|
|
|
|
show_previous_messaging_sessions=False,
|
|
|
|
|
show_cron_sessions=False,
|
|
|
|
|
)
|
|
|
|
|
return payload, pruned
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_orphaned_api_server_sidecar_is_pruned_from_sidebar_payload(monkeypatch):
|
|
|
|
|
"""API-server sidecars take the same exact state.db orphan prune path."""
|
|
|
|
|
row = {
|
|
|
|
|
"session_id": "api-orphan",
|
|
|
|
|
"title": "API Session",
|
|
|
|
|
"profile": "default",
|
|
|
|
|
"updated_at": 20,
|
|
|
|
|
"last_message_at": 20,
|
|
|
|
|
"message_count": 2,
|
|
|
|
|
"read_only": True,
|
|
|
|
|
"source_tag": "api_server",
|
|
|
|
|
"raw_source": "api_server",
|
|
|
|
|
"session_source": "api",
|
|
|
|
|
"source_label": "API",
|
|
|
|
|
"is_cli_session": False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload, pruned = _payload_for_rows(monkeypatch, [row], existing_ids=[])
|
|
|
|
|
|
|
|
|
|
assert [session["session_id"] for session in payload["sessions"]] == []
|
|
|
|
|
assert pruned == ["api-orphan"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_server_sidecar_with_backing_state_row_is_retained(monkeypatch):
|
|
|
|
|
"""Absence from get_cli_sessions() alone is not enough to prune API rows."""
|
|
|
|
|
row = {
|
|
|
|
|
"session_id": "api-live",
|
|
|
|
|
"title": "API Session",
|
|
|
|
|
"profile": "default",
|
|
|
|
|
"updated_at": 20,
|
|
|
|
|
"last_message_at": 20,
|
|
|
|
|
"message_count": 2,
|
|
|
|
|
"read_only": True,
|
|
|
|
|
"source_tag": "api_server",
|
|
|
|
|
"raw_source": "api_server",
|
|
|
|
|
"session_source": "api",
|
|
|
|
|
"source_label": "API",
|
|
|
|
|
"is_cli_session": False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload, pruned = _payload_for_rows(monkeypatch, [row], existing_ids=["api-live"])
|
|
|
|
|
|
|
|
|
|
assert [session["session_id"] for session in payload["sessions"]] == ["api-live"]
|
|
|
|
|
assert pruned == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_webui_owned_session_with_api_metadata_is_not_pruned(monkeypatch):
|
|
|
|
|
"""A native WebUI row must stay safe even if stale metadata mentions API."""
|
|
|
|
|
row = {
|
|
|
|
|
"session_id": "webui-native",
|
|
|
|
|
"title": "Native WebUI",
|
|
|
|
|
"profile": "default",
|
|
|
|
|
"updated_at": 20,
|
|
|
|
|
"last_message_at": 20,
|
|
|
|
|
"message_count": 2,
|
|
|
|
|
"read_only": False,
|
|
|
|
|
"source_tag": "webui",
|
|
|
|
|
"raw_source": "api_server",
|
|
|
|
|
"session_source": "webui",
|
|
|
|
|
"source_label": "API",
|
|
|
|
|
"is_cli_session": False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload, pruned = _payload_for_rows(monkeypatch, [row], existing_ids=[])
|
|
|
|
|
|
|
|
|
|
assert [session["session_id"] for session in payload["sessions"]] == ["webui-native"]
|
|
|
|
|
assert pruned == []
|