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.
This commit is contained in:
Alessandro 2026-10-13 14:03:22 +02:00
parent d4e1c85352
commit 3d187a81a5
4 changed files with 41 additions and 195 deletions

View file

@ -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`

View file

@ -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.

View file

@ -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

View file

@ -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)