Prune deleted saved chats from sidebar state

Mark chats after successful persistence and reconcile saved chat IDs while building WebUI state snapshots so stale in-memory contexts disappear after their chat files are removed. Keep fresh unsaved chats visible, skip running contexts, and cover the regression with snapshot and persistence tests.

Alessandro committed Jul 1, 2026 at 15:59 UTC 6ab402172f14ecf9950063c4604f2657a96c7133
6 files changed +72 -4
helpers/persist_chat.py
+16
@@ -14,6 +14,7 @@ from helpers.log import Log, LogItem
14 CHATS_FOLDER = "usr/chats"
15 LOG_SIZE = 1000
16 CHAT_FILE_NAME = "chat.json"
17 +SAVED_CHAT_CONTEXT_DATA_KEY = "_persist_chat_saved"
18
19
20 def _fallback_datetime_iso() -> str:
@@ -54,6 +55,7 @@ def save_tmp_chat(context: AgentContext):
55 data = _serialize_context(context)
56 js = _safe_json_serialize(data, ensure_ascii=False)
57 files.write_file(path, js)
58 + mark_chat_saved(context)
59
60
61 def save_tmp_chats():
@@ -81,6 +83,7 @@ def load_tmp_chats():
83 js = files.read_file(file)
84 data = json.loads(js)
85 ctx = _deserialize_context(data)
86 + mark_chat_saved(ctx)
87 ctxids.append(ctx.id)
88 except Exception as e:
89 print(f"Error loading chat {file}: {e}")
@@ -91,6 +94,19 @@ def _get_chat_file_path(ctxid: str):
94 return files.get_abs_path(CHATS_FOLDER, ctxid, CHAT_FILE_NAME)
95
96
97 +def mark_chat_saved(context: AgentContext) -> None:
98 + context.data[SAVED_CHAT_CONTEXT_DATA_KEY] = True
99 +
100 +
101 +def saved_chat_ids() -> set[str]:
102 + return {
103 + files.basename(files.dirname(path))
104 + for path in files.find_existing_paths_by_pattern(
105 + files.get_abs_path(CHATS_FOLDER, "*", CHAT_FILE_NAME)
106 + )
107 + }
108 +
109 +
110 def _convert_v080_chats():
111 json_files = files.list_files(CHATS_FOLDER, "*.json")
112 for file in json_files:
helpers/persist_chat.py.dox.md
+4 -1
@@ -24,6 +24,8 @@
24 - `export_json_chat(context: AgentContext)`: Export context as JSON string
25 - `remove_chat(ctxid)`: Remove a chat or task context
26 - `remove_msg_files(ctxid)`: Remove all message files for a chat or task context
27 +- `mark_chat_saved(context: AgentContext) -> None`
28 +- `saved_chat_ids() -> set[str]`
29 - `_serialize_context(context: AgentContext)`
30 - `_serialize_agent(agent: Agent)`
31 - `_serialize_log(log: Log)`
@@ -32,7 +34,7 @@
34 - `_deserialize_agents(agents: list[dict[str, Any]], config: AgentConfig, context: AgentContext) -> Agent`
35 - `_deserialize_log(data: dict[str, Any]) -> 'Log'`
36 - `_safe_json_serialize(obj, **kwargs)`
35 -- Notable constants/configuration names: `CHATS_FOLDER`, `LOG_SIZE`, `CHAT_FILE_NAME`.
37 +- Notable constants/configuration names: `CHATS_FOLDER`, `LOG_SIZE`, `CHAT_FILE_NAME`, `SAVED_CHAT_CONTEXT_DATA_KEY`.
38
39 ## Runtime Contracts
40
@@ -43,6 +45,7 @@
45 - Serialized chats store `agent_profile` both at the context level for the main chat and on each serialized agent so subordinate profiles survive server restart.
46 - Deserialization must rebuild each agent with its serialized profile when present, falling back to the context profile for older chat files.
47 - Chat loading skips directories that do not contain `chat.json`; malformed existing chat files still report load errors.
48 +- Contexts are marked with private `SAVED_CHAT_CONTEXT_DATA_KEY` only after a successful save or load from disk so snapshot code can detect deleted chat files without hiding fresh unsaved chats.
49
50 ## Key Concepts
51
helpers/state_snapshot.py
+16
@@ -140,6 +140,20 @@ def _apply_agent_profile_metadata(
140 context_data["agent_profile_label"] = labels.get(profile, profile) if profile else ""
141
142
143 +def _prune_missing_saved_contexts() -> None:
144 + from helpers import persist_chat
145 +
146 + saved_ids = persist_chat.saved_chat_ids()
147 + for ctx in AgentContext.all():
148 + if ctx.type == AgentContextType.BACKGROUND or ctx.is_running():
149 + continue
150 + if (
151 + ctx.data.get(persist_chat.SAVED_CHAT_CONTEXT_DATA_KEY)
152 + and ctx.id not in saved_ids
153 + ):
154 + AgentContext.remove(ctx.id)
155 +
156 +
157 def parse_state_request_payload(payload: Mapping[str, Any]) -> StateRequestV1:
158 context = payload.get("context")
159 log_from = payload.get("log_from")
@@ -255,6 +269,8 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
269 from_no = _coerce_non_negative_int(request.log_from, default=0)
270 notifications_from_no = _coerce_non_negative_int(request.notifications_from, default=0)
271
272 + _prune_missing_saved_contexts()
273 +
274 active_context = AgentContext.get(ctxid) if ctxid else None
275
276 if active_context:
helpers/state_snapshot.py.dox.md
+4 -2
@@ -21,6 +21,7 @@
21 - `_coerce_non_negative_int(value: Any, default: int=...) -> int`
22 - `_get_agent_profile_labels() -> dict[str, str]`
23 - `_apply_agent_profile_metadata(context_data: dict[str, Any], ctx: AgentContext, labels: dict[str, str]) -> None`
24 +- `_prune_missing_saved_contexts() -> None`
25 - `parse_state_request_payload(payload: Mapping[str, Any]) -> StateRequestV1`
26 - `_coerce_state_request_inputs(context: Any, log_from: Any, notifications_from: Any, timezone: Any) -> StateRequestV1`
27 - `advance_state_request_after_snapshot(request: StateRequestV1, snapshot: Mapping[str, Any]) -> StateRequestV1`
@@ -33,12 +34,13 @@
34
35 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
36 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
36 -- Observed side-effect areas: plugin state, settings/state persistence, secret handling, scheduler state.
37 -- Imported dependency areas include: `__future__`, `agent`, `dataclasses`, `helpers.dotenv`, `helpers.localization`, `helpers.task_scheduler`, `pytz`, `types`, `typing`.
37 +- Observed side-effect areas: filesystem reads, plugin state, settings/state persistence, secret handling, scheduler state, in-memory context removal.
38 +- Imported dependency areas include: `__future__`, `agent`, `dataclasses`, `helpers.dotenv`, `helpers.localization`, `helpers.persist_chat`, `helpers.task_scheduler`, `pytz`, `types`, `typing`.
39
40 ## Key Concepts
41
42 - Important called helpers/classes observed in the source: `dataclass`, `_build_schema_from_typeddict`, `get_origin`, `timezone.strip`, `StateRequestV1`, `localization.get_timezone`, `localization.set_timezone`, `ctxid.strip`, `_coerce_non_negative_int`, `AgentContext.get_notification_manager`, `notification_manager.output`, `_get_agent_profile_labels`, `ctxs.sort`, `tasks.sort`, `validate_snapshot_schema_v1`, `_coerce_state_request_inputs`, `super.__init__`, `get_args`, `_annotation_to_isinstance_types`, `TypeError`.
43 +- Snapshot building prunes non-running in-memory contexts that were previously saved but no longer have a `chat.json`, preventing stale sidebar rows after chat files are deleted outside `/chat_remove`.
44 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
45
46 ## Work Guidance
tests/test_persist_chat_log_ids.py
+1 -1
@@ -48,7 +48,7 @@ def test_load_tmp_chats_skips_directories_without_chat_json(monkeypatch, capsys)
48 monkeypatch.setattr(
49 persist_chat,
50 "_deserialize_context",
51 - lambda data: SimpleNamespace(id=data["id"]),
51 + lambda data: SimpleNamespace(id=data["id"], data={}),
52 )
53
54 assert persist_chat.load_tmp_chats() == ["valid"]
tests/test_snapshot_parity.py
+31
@@ -75,3 +75,34 @@ async def test_snapshot_builder_active_context_includes_incremental_logs():
75 assert second["log_version"] == first["log_version"]
76 finally:
77 AgentContext.remove(ctxid)
78 +
79 +
80 +@pytest.mark.asyncio
81 +async def test_snapshot_prunes_saved_context_missing_from_chat_files(monkeypatch):
82 + from helpers import persist_chat
83 + from helpers import state_snapshot as snapshot
84 +
85 + missing_id = "ctx-saved-missing-chat-file"
86 + unsaved_id = "ctx-unsaved-chat-file"
87 + missing = AgentContext(config=initialize_agent(), id=missing_id, set_current=False)
88 + unsaved = AgentContext(config=initialize_agent(), id=unsaved_id, set_current=False)
89 + persist_chat.mark_chat_saved(missing)
90 + monkeypatch.setattr(persist_chat, "saved_chat_ids", lambda: set())
91 +
92 + try:
93 + payload = await snapshot.build_snapshot(
94 + context=missing_id,
95 + log_from=0,
96 + notifications_from=0,
97 + timezone="UTC",
98 + )
99 +
100 + context_ids = {ctx["id"] for ctx in payload["contexts"]}
101 + assert payload["deselect_chat"] is True
102 + assert payload["context"] == ""
103 + assert missing_id not in context_ids
104 + assert unsaved_id in context_ids
105 + assert AgentContext.get(missing_id) is None
106 + finally:
107 + AgentContext.remove(missing_id)
108 + AgentContext.remove(unsaved_id)