From 3d187a81a5b69ab4666a3d86ea05e417b9606169 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:03:22 +0200 Subject: [PATCH] test: isolate saved chat pruning regression Replace the module-stubbing test file with a snapshot regression that saves a real chat after the saved-ID listing is captured. This covers the first-save race without leaking fake core modules into later tests. Keep deletion and unsaved-chat coverage in a temporary chat directory, and document the disk recheck in the persistence and snapshot helper DOX. Validation: 24 focused tests passed in the Docker framework runtime. The new regression fails when the disk recheck is removed. Helper DOX coverage and git diff --check passed. --- helpers/persist_chat.py.dox.md | 3 + helpers/state_snapshot.py.dox.md | 2 +- tests/test_prune_missing_saved_contexts.py | 192 --------------------- tests/test_snapshot_parity.py | 39 ++++- 4 files changed, 41 insertions(+), 195 deletions(-) delete mode 100644 tests/test_prune_missing_saved_contexts.py diff --git a/helpers/persist_chat.py.dox.md b/helpers/persist_chat.py.dox.md index 36b62e8c9..828ee0880 100644 --- a/helpers/persist_chat.py.dox.md +++ b/helpers/persist_chat.py.dox.md @@ -19,6 +19,7 @@ - `save_tmp_chats()`: Save all contexts to the chats folder - `load_tmp_chats()`: Load all contexts from the chats folder - `_get_chat_file_path(ctxid: str)` +- `chat_file_exists(ctxid: str) -> bool` - `_convert_v080_chats()` - `load_json_chats(jsons: list[str])`: Load contexts from JSON strings - `export_json_chat(context: AgentContext)`: Export context as JSON string @@ -48,6 +49,7 @@ - `load_tmp_chats()` reuses one Git version through `settings.version_cache_scope()` for its per-file restoration loop. Each agent still receives normal settings/credential reads and initialization hooks; the scope expires after the load, including failures. - Chat saves write and fsync a same-directory temporary file, atomically replace `chat.json`, and fsync the directory so an interrupted save cannot truncate the previous chat. - 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. +- `chat_file_exists(ctxid)` reads the current `chat.json` path so snapshot pruning can confirm a missing file after taking an earlier directory listing. - Chat and message-file removal reject empty or whitespace-only context IDs before provider or filesystem deletion begins. ## Key Concepts @@ -68,6 +70,7 @@ - `tests/test_api_chat_lifetime.py` - `tests/test_browser_agent_regressions.py` - `tests/test_persist_chat_log_ids.py` + - `tests/test_snapshot_parity.py` - `tests/test_subagent_profiles.py` - `tests/test_tool_action_contracts.py` diff --git a/helpers/state_snapshot.py.dox.md b/helpers/state_snapshot.py.dox.md index e0c4190ae..38eb07c81 100644 --- a/helpers/state_snapshot.py.dox.md +++ b/helpers/state_snapshot.py.dox.md @@ -40,7 +40,7 @@ ## Key Concepts - 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`. -- 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`. +- 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`. A context absent from the saved-ID listing is rechecked with `persist_chat.chat_file_exists()` before removal so a concurrent first save cannot lose its in-memory context. - Notification payloads use the manager's matching GUID and cursor from the same atomic read, preventing a concurrent notification from being skipped by the WebUI. - `StateRequestV1.collections_delta` is an optional, false-by-default capability. Negotiated state pushes may use `null` for both `contexts` and `tasks` when those collections are unchanged; HTTP polling and legacy WebSocket clients always receive full lists. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. diff --git a/tests/test_prune_missing_saved_contexts.py b/tests/test_prune_missing_saved_contexts.py deleted file mode 100644 index 38c9f740d..000000000 --- a/tests/test_prune_missing_saved_contexts.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Regression for agent0ai/agent-zero#1947. - -A stale saved_chat_ids() listing taken before a concurrent first save must not -remove the newly saved context. Confirm on disk before removing. - -Heavy Agent Zero runtime deps are stubbed so this file stays runnable in a -minimal pytest + pytz environment. -""" - -from __future__ import annotations - -import enum -import importlib -import sys -import types -from pathlib import Path -from types import SimpleNamespace - -import pytest - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -pytest.importorskip("pytz") - - -def _ensure_module(name: str) -> types.ModuleType: - if name in sys.modules: - return sys.modules[name] - mod = types.ModuleType(name) - sys.modules[name] = mod - if "." in name: - parent_name, attr = name.rsplit(".", 1) - parent = _ensure_module(parent_name) - setattr(parent, attr, mod) - return mod - - -def _install_stubs() -> types.ModuleType: - """Install lightweight stubs when the real agent stack is unavailable.""" - try: - import agent as real_agent # noqa: F401 - - return sys.modules["agent"] - except Exception: - pass - - agent_mod = _ensure_module("agent") - - class AgentContextType(enum.Enum): - USER = "user" - TASK = "task" - BACKGROUND = "background" - - class AgentContext: - _contexts: dict = {} - - @classmethod - def all(cls): - return list(cls._contexts.values()) - - @classmethod - def remove(cls, ctxid: str): - cls._contexts.pop(ctxid, None) - - @classmethod - def reset_for_tests(cls): - cls._contexts.clear() - - agent_mod.AgentContext = AgentContext - agent_mod.AgentContextType = AgentContextType - agent_mod.Agent = object - agent_mod.AgentConfig = object - - # state_snapshot top-level helper imports - dotenv = _ensure_module("helpers.dotenv") - dotenv.get_dotenv_value = lambda *a, **k: None - - localization = _ensure_module("helpers.localization") - - class Localization: - @staticmethod - def get(): - return Localization() - - def get_timezone(self): - return "UTC" - - def set_timezone(self, tz): - return None - - localization.Localization = Localization - - task_scheduler = _ensure_module("helpers.task_scheduler") - - class TaskScheduler: - @staticmethod - def get(): - return TaskScheduler() - - def get_task_by_uuid(self, _id): - return None - - def serialize_task(self, _id): - return None - - task_scheduler.TaskScheduler = TaskScheduler - - # persist_chat stand-in for the inner import in _prune_missing_saved_contexts - persist_chat = _ensure_module("helpers.persist_chat") - if not hasattr(persist_chat, "SAVED_CHAT_CONTEXT_DATA_KEY"): - persist_chat.SAVED_CHAT_CONTEXT_DATA_KEY = "_persist_chat_saved" - if not hasattr(persist_chat, "saved_chat_ids"): - persist_chat.saved_chat_ids = lambda: set() - if not hasattr(persist_chat, "chat_file_exists"): - persist_chat.chat_file_exists = lambda ctxid: False - - return agent_mod - - -@pytest.fixture -def prune_env(monkeypatch): - agent_mod = _install_stubs() - - # Fresh load of state_snapshot against stubs - sys.modules.pop("helpers.state_snapshot", None) - state_snapshot = importlib.import_module("helpers.state_snapshot") - persist_chat = sys.modules["helpers.persist_chat"] - - if hasattr(agent_mod.AgentContext, "reset_for_tests"): - agent_mod.AgentContext.reset_for_tests() - - yield state_snapshot, persist_chat, agent_mod - - if hasattr(agent_mod.AgentContext, "reset_for_tests"): - agent_mod.AgentContext.reset_for_tests() - - -def test_prune_keeps_context_when_stale_listing_misses_existing_file(prune_env, monkeypatch): - state_snapshot, persist_chat, agent_mod = prune_env - AgentContext = agent_mod.AgentContext - AgentContextType = agent_mod.AgentContextType - - removed: list[str] = [] - ctx = SimpleNamespace( - id="just-saved", - type=AgentContextType.USER, - data={persist_chat.SAVED_CHAT_CONTEXT_DATA_KEY: True}, - is_running=lambda: False, - ) - - monkeypatch.setattr(AgentContext, "all", classmethod(lambda cls: [ctx])) - monkeypatch.setattr( - AgentContext, "remove", classmethod(lambda cls, ctxid: removed.append(ctxid)) - ) - monkeypatch.setattr(persist_chat, "saved_chat_ids", lambda: set()) - monkeypatch.setattr(persist_chat, "chat_file_exists", lambda ctxid: ctxid == "just-saved") - - state_snapshot._prune_missing_saved_contexts() - assert removed == [] - - -def test_prune_removes_context_when_file_truly_gone(prune_env, monkeypatch): - state_snapshot, persist_chat, agent_mod = prune_env - AgentContext = agent_mod.AgentContext - AgentContextType = agent_mod.AgentContextType - - removed: list[str] = [] - ctx = SimpleNamespace( - id="deleted-chat", - type=AgentContextType.USER, - data={persist_chat.SAVED_CHAT_CONTEXT_DATA_KEY: True}, - is_running=lambda: False, - ) - - monkeypatch.setattr(AgentContext, "all", classmethod(lambda cls: [ctx])) - monkeypatch.setattr( - AgentContext, "remove", classmethod(lambda cls, ctxid: removed.append(ctxid)) - ) - monkeypatch.setattr(persist_chat, "saved_chat_ids", lambda: set()) - monkeypatch.setattr(persist_chat, "chat_file_exists", lambda ctxid: False) - - state_snapshot._prune_missing_saved_contexts() - assert removed == ["deleted-chat"] - - -def test_source_contract_chat_file_exists_recheck(): - persist = (PROJECT_ROOT / "helpers" / "persist_chat.py").read_text(encoding="utf-8") - snapshot = (PROJECT_ROOT / "helpers" / "state_snapshot.py").read_text(encoding="utf-8") - assert "def chat_file_exists(ctxid: str)" in persist - assert "persist_chat.chat_file_exists(ctx.id)" in snapshot diff --git a/tests/test_snapshot_parity.py b/tests/test_snapshot_parity.py index ddb433609..b31b3488d 100644 --- a/tests/test_snapshot_parity.py +++ b/tests/test_snapshot_parity.py @@ -78,16 +78,16 @@ async def test_snapshot_builder_active_context_includes_incremental_logs(): @pytest.mark.asyncio -async def test_snapshot_prunes_saved_context_missing_from_chat_files(monkeypatch): +async def test_snapshot_prunes_saved_context_missing_from_chat_files(monkeypatch, tmp_path): from helpers import persist_chat from helpers import state_snapshot as snapshot + monkeypatch.setattr(persist_chat, "CHATS_FOLDER", str(tmp_path)) missing_id = "ctx-saved-missing-chat-file" unsaved_id = "ctx-unsaved-chat-file" missing = AgentContext(config=initialize_agent(), id=missing_id, set_current=False) unsaved = AgentContext(config=initialize_agent(), id=unsaved_id, set_current=False) persist_chat.mark_chat_saved(missing) - monkeypatch.setattr(persist_chat, "saved_chat_ids", lambda: set()) try: payload = await snapshot.build_snapshot( @@ -106,3 +106,38 @@ async def test_snapshot_prunes_saved_context_missing_from_chat_files(monkeypatch finally: AgentContext.remove(missing_id) AgentContext.remove(unsaved_id) + + +@pytest.mark.asyncio +async def test_snapshot_keeps_context_saved_after_chat_file_listing(monkeypatch, tmp_path): + from helpers import persist_chat + from helpers import state_snapshot as snapshot + + monkeypatch.setattr(persist_chat, "CHATS_FOLDER", str(tmp_path)) + ctxid = "ctx-just-saved-chat-file" + ctx = AgentContext(config=initialize_agent(), id=ctxid, set_current=False) + saved_chat_ids = persist_chat.saved_chat_ids + + def save_after_listing(): + saved_ids = saved_chat_ids() + assert ctxid not in saved_ids + assert not ctx.data.get(persist_chat.SAVED_CHAT_CONTEXT_DATA_KEY) + persist_chat.save_tmp_chat(ctx) + return saved_ids + + monkeypatch.setattr(persist_chat, "saved_chat_ids", save_after_listing) + + try: + payload = await snapshot.build_snapshot( + context=ctxid, + log_from=0, + notifications_from=0, + timezone="UTC", + ) + + assert payload["deselect_chat"] is False + assert payload["context"] == ctxid + assert ctxid in {context["id"] for context in payload["contexts"]} + assert AgentContext.get(ctxid) is ctx + finally: + AgentContext.remove(ctxid)