mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-10-19 07:23:51 +00:00
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:
parent
d4e1c85352
commit
3d187a81a5
4 changed files with 41 additions and 195 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue