Align Responses cache affinity across execution and summary calls, preserve required checks and evidence uncertainty, and allow safe deletion of inactive explorations while retaining independent reruns. Validation: backend suite passed except intentional prompt golden drift, then all 17 transcript tests passed after snapshot refresh; 268 frontend tests and production build passed; Ruff, quality ratchet and core type checks passed.
167 lines
7.3 KiB
Python
167 lines
7.3 KiB
Python
import json
|
|
from types import SimpleNamespace
|
|
from uuid import UUID
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
import pytest
|
|
|
|
from apps.admin_console.routers import sessions
|
|
from apps.admin_console.services import exploration_deletion as deletion
|
|
from apps.admin_console.services import report_export
|
|
from apps.admin_console.services.automation_store import AutomationStore
|
|
from artemis.config.exploration import ExplorationConfig
|
|
from artemis.data_engine.storage import StorageManager
|
|
from artemis.exploration import store as store_module
|
|
from artemis.exploration.models import CaseDraft
|
|
from artemis.exploration.store import ExplorationConflict, ExplorationStore
|
|
|
|
|
|
@pytest.fixture
|
|
def setup(tmp_path, monkeypatch):
|
|
db = tmp_path / "data.db"
|
|
traces = tmp_path / "traces"
|
|
storage = StorageManager(db, traces)
|
|
repository = ExplorationStore(AutomationStore(db))
|
|
monkeypatch.setattr(store_module, "explorations", repository)
|
|
monkeypatch.setattr(report_export, "TRACES_PATH", traces)
|
|
monkeypatch.setattr(report_export, "EXPORT_ROOT", tmp_path / "exports")
|
|
monkeypatch.setattr(deletion, "state", SimpleNamespace(queue_items=[], active_runs={}))
|
|
monkeypatch.setattr(deletion, "read_state", lambda _: {})
|
|
app = FastAPI()
|
|
app.include_router(sessions.router)
|
|
session = repository.create("探索页面", ExplorationConfig(), {}, "create")
|
|
return repository, session["id"], storage, TestClient(app)
|
|
|
|
|
|
def make_run(repository, sid, request, *, kind="exploration", finished=True):
|
|
run = repository.reserve_run(
|
|
sid,
|
|
request,
|
|
kind=kind,
|
|
goal="观察",
|
|
task_config={},
|
|
case_version=1 if kind == "validation" else None,
|
|
)
|
|
if finished:
|
|
repository.finish_run(run["id"], {"status": "cancelled"})
|
|
return run["id"]
|
|
|
|
|
|
def test_delete_conversation_removes_all_exploration_runs_but_keeps_reruns(setup):
|
|
repository, sid, storage, client = setup
|
|
first = make_run(repository, sid, "first")
|
|
second = make_run(repository, sid, "continue")
|
|
repository.save_case(sid, CaseDraft(name="可复用用例", goal="检查页面"), 0, "case")
|
|
validation = make_run(repository, sid, "validate", kind="validation")
|
|
unrelated = repository.create("另一探索", ExplorationConfig(), {}, "other")["id"]
|
|
directories = []
|
|
for run_id in (first, second, validation):
|
|
repository.database.save_job({"session_id": run_id, "status": "cancelled", "position": 1})
|
|
with repository.database.connection() as connection:
|
|
connection.execute(
|
|
"INSERT INTO sessions (session_id, initial_goal, start_time, status, device_info) VALUES (?, ?, 1, ?, '{}')",
|
|
(run_id, "观察", "cancelled"),
|
|
)
|
|
connection.commit()
|
|
for root in (storage.base_trace_dir, report_export.EXPORT_ROOT / "sessions"):
|
|
folder = root / run_id
|
|
folder.mkdir(parents=True)
|
|
(folder / "evidence.txt").write_text("saved", encoding="utf-8")
|
|
if run_id != validation:
|
|
directories.append(folder)
|
|
with repository._transaction() as connection:
|
|
session = repository._require(connection, sid)
|
|
session["status"] = "paused" # Resumable conversation, no running process.
|
|
repository._save_session(connection, session)
|
|
response = client.post(f"/api/sessions/{first}/delete")
|
|
assert response.status_code == 200, response.text
|
|
assert set(response.json()["deleted_session_ids"]) == {first, second}
|
|
assert not any(folder.exists() for folder in directories)
|
|
assert (storage.base_trace_dir / validation / "evidence.txt").exists()
|
|
assert repository.database.job(validation) is not None
|
|
assert repository.database.job(first) is None
|
|
assert storage.get_session(UUID(validation)) is not None
|
|
assert storage.get_session(UUID(first)) is None
|
|
assert repository.get(unrelated)["goal"] == "另一探索"
|
|
assert repository.run(validation) is None # Detach it from the deleted conversation.
|
|
deleted_requests = [
|
|
record
|
|
for record in repository.database.records("exploration_request")
|
|
if record.get("deleted")
|
|
]
|
|
assert len(deleted_requests) >= 5
|
|
assert all("result" not in record for record in deleted_requests)
|
|
with repository.database.connection() as connection:
|
|
assert not connection.execute(
|
|
"SELECT 1 FROM automation_records WHERE kind != 'exploration_request' "
|
|
"AND json_extract(payload,'$.exploration_id')=?",
|
|
(sid,),
|
|
).fetchone()
|
|
with pytest.raises(KeyError):
|
|
repository.get(sid)
|
|
with pytest.raises(ExplorationConflict, match="已删除"):
|
|
repository.create("探索页面", ExplorationConfig(), {}, "create")
|
|
|
|
|
|
@pytest.mark.parametrize("state", ["running", "paused", "waiting_user", "demonstrating"])
|
|
def test_live_execution_cannot_be_deleted_even_through_an_old_run(setup, state):
|
|
repository, sid, _, client = setup
|
|
first = make_run(repository, sid, "first")
|
|
active = make_run(repository, sid, "continue", finished=False)
|
|
repository.set_run_state(active, state)
|
|
response = client.post(f"/api/sessions/{first}/delete")
|
|
assert response.status_code == 409
|
|
assert repository.get(sid)["active_run_id"] == active
|
|
assert repository.run(first) is not None
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["exploration_generation", "exploration_discussion"])
|
|
def test_editorial_work_prevents_deletion(setup, kind):
|
|
repository, sid, _, client = setup
|
|
run_id = make_run(repository, sid, "first")
|
|
repository.database.put(kind, "job", {"exploration_id": sid, "status": "running"})
|
|
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 409
|
|
assert repository.get(sid)["status"] == "cancelled"
|
|
|
|
|
|
def test_worker_still_exiting_prevents_deletion(setup, monkeypatch):
|
|
repository, sid, _, client = setup
|
|
run_id = make_run(repository, sid, "first")
|
|
monkeypatch.setattr(
|
|
deletion, "state", SimpleNamespace(queue_items=[], active_runs={run_id: {}})
|
|
)
|
|
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 409
|
|
assert repository.run(run_id) is not None
|
|
|
|
|
|
def test_cleanup_failure_is_retryable_and_blocks_new_work(setup, monkeypatch):
|
|
repository, sid, storage, client = setup
|
|
run_id = make_run(repository, sid, "first")
|
|
original = StorageManager.delete_session
|
|
|
|
def fail(*args):
|
|
raise OSError("file busy")
|
|
|
|
monkeypatch.setattr(StorageManager, "delete_session", fail)
|
|
response = client.post(f"/api/sessions/{run_id}/delete")
|
|
assert response.status_code == 500
|
|
with pytest.raises(ExplorationConflict, match="正在删除"):
|
|
make_run(repository, sid, "restart")
|
|
monkeypatch.setattr(StorageManager, "delete_session", original)
|
|
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 200
|
|
assert not repository.list()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delayed_discussion_cannot_recreate_deleted_metadata(setup):
|
|
from artemis.exploration.editorial import discuss
|
|
|
|
repository, sid, _, client = setup
|
|
run_id = make_run(repository, sid, "first")
|
|
message = repository.add_message(sid, "不再继续", "message")
|
|
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 200
|
|
with pytest.raises(KeyError):
|
|
await discuss(repository, sid, message)
|
|
assert not repository.database.records("exploration_discussion")
|