205 lines
7.6 KiB
Python
205 lines
7.6 KiB
Python
import asyncio
|
|
import json
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
|
|
from apps.admin_console.services.automation_store import AutomationStore
|
|
from artemis.config.exploration import ExplorationConfig
|
|
from artemis.exploration.models import CaseDraft
|
|
from artemis.exploration.store import ExplorationStore, ExplorationConflict
|
|
from artemis.exploration.synthesis import generate_case
|
|
from artemis.runtime import task_control
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_editorial_never_consumes_device_guidance_or_restarts_recording(tmp_path):
|
|
from artemis.exploration.synthesis import _invoke_editorial
|
|
|
|
directory = tmp_path / "instructions"
|
|
directory.mkdir()
|
|
pending = directory / "user.json"
|
|
pending.write_text('{"instruction":"return home"}', encoding="utf-8")
|
|
control = SimpleNamespace(
|
|
directory=tmp_path, checkpoint=AsyncMock(), run_interruptibly=AsyncMock()
|
|
)
|
|
ctx = SimpleNamespace(task_control=control, on_task_resume=AsyncMock())
|
|
model = SimpleNamespace(ainvoke=AsyncMock(return_value="case"))
|
|
assert await _invoke_editorial(ctx, model, []) == "case"
|
|
assert pending.exists()
|
|
control.checkpoint.assert_not_called()
|
|
control.run_interruptibly.assert_not_called()
|
|
ctx.on_task_resume.assert_not_called()
|
|
|
|
|
|
@pytest.fixture
|
|
def synthesis(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path / "traces")
|
|
repository = ExplorationStore(AutomationStore(tmp_path / "history.db"))
|
|
session = repository.create("打开设置并确认状态", ExplorationConfig(), {}, "create")
|
|
run = repository.reserve_run(
|
|
session["id"], "start", kind="exploration", goal="探索", task_config={}
|
|
)
|
|
outcome = {
|
|
"status": "completed",
|
|
"test_result": {"status": "passed", "reason": "状态正确", "evidence": ["步骤3设置开启"]},
|
|
}
|
|
repository.finish_run(run["id"], outcome)
|
|
root = task_control.control_dir(run["id"])
|
|
root.mkdir(parents=True)
|
|
(root / "exploration-evidence.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"plan": "已完成设置检查",
|
|
"outcome": outcome,
|
|
"steps": [{"step_number": 3, "summary": "设置开启"}],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
model = Mock()
|
|
model.with_structured_output.return_value.ainvoke = AsyncMock(
|
|
return_value=CaseDraft(
|
|
name="设置检查", goal="打开设置,验证开关为开启。", evidence=["步骤3"]
|
|
)
|
|
)
|
|
monkeypatch.setattr("artemis.exploration.synthesis.get_llm", Mock(return_value=model))
|
|
return repository, session, run, model
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generation_is_idempotent_and_preserves_rerun_independence(synthesis):
|
|
repository, session, run, model = synthesis
|
|
ctx = SimpleNamespace(task_control=None)
|
|
result = await generate_case(repository, session["id"], "generate", run["id"], 0, ctx=ctx)
|
|
duplicate = await generate_case(repository, session["id"], "generate", run["id"], 0, ctx=ctx)
|
|
assert result == duplicate
|
|
assert result["version"] == 1
|
|
assert result["source_run_id"] == run["id"]
|
|
assert "validation_status" not in result
|
|
assert len(repository.runs(session["id"])) == 1
|
|
model.with_structured_output.return_value.ainvoke.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_model_generation_cannot_overwrite_a_concurrent_manual_revision(synthesis):
|
|
repository, session, run, model = synthesis
|
|
started, finish = asyncio.Event(), asyncio.Event()
|
|
|
|
async def generate(_):
|
|
started.set()
|
|
await finish.wait()
|
|
return CaseDraft(name="模型", goal="模型改动")
|
|
|
|
model.with_structured_output.return_value.ainvoke = generate
|
|
pending = asyncio.create_task(
|
|
generate_case(
|
|
repository,
|
|
session["id"],
|
|
"generate",
|
|
run["id"],
|
|
0,
|
|
ctx=SimpleNamespace(task_control=None),
|
|
)
|
|
)
|
|
await started.wait()
|
|
repository.save_case(session["id"], CaseDraft(name="用户", goal="用户修订"), 0, "manual")
|
|
finish.set()
|
|
with pytest.raises(ExplorationConflict):
|
|
await pending
|
|
assert repository.case(session["id"])["goal"] == "用户修订"
|
|
assert repository.database.records("exploration_generation")[0]["status"] == "failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interrupted_execution_cannot_generate_a_verified_case(synthesis):
|
|
repository, session, run, model = synthesis
|
|
stored = repository.run(run["id"])
|
|
stored["outcome"]["status"] = "interrupted"
|
|
repository.database.put("exploration_run", run["id"], stored)
|
|
with pytest.raises(ExplorationConflict, match="尚未验证通过"):
|
|
await generate_case(
|
|
repository,
|
|
session["id"],
|
|
"generate",
|
|
run["id"],
|
|
0,
|
|
ctx=SimpleNamespace(task_control=None),
|
|
)
|
|
model.with_structured_output.return_value.ainvoke.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_synthesis_failure_leaves_execution_verdict_unchanged(synthesis):
|
|
repository, session, run, model = synthesis
|
|
model.with_structured_output.return_value.ainvoke = AsyncMock(
|
|
side_effect=ValueError("malformed response")
|
|
)
|
|
with pytest.raises(ValueError):
|
|
await generate_case(
|
|
repository,
|
|
session["id"],
|
|
"generate",
|
|
run["id"],
|
|
0,
|
|
ctx=SimpleNamespace(task_control=None),
|
|
)
|
|
assert repository.run(run["id"])["outcome"]["test_result"]["status"] == "passed"
|
|
assert repository.case(session["id"]) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_initialization_failure_marks_generation_retryable(synthesis, monkeypatch):
|
|
repository, session, run, model = synthesis
|
|
monkeypatch.setattr(
|
|
"artemis.exploration.synthesis.get_default_llm_config",
|
|
Mock(side_effect=ValueError("bad config")),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
await generate_case(repository, session["id"], "generate", run["id"], 0)
|
|
assert repository.database.records("exploration_generation")[0]["status"] == "failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_long_path_compaction_covers_beginning_and_end(monkeypatch):
|
|
from artemis.exploration.synthesis import _complete_path, PathSummary
|
|
|
|
seen = []
|
|
|
|
async def invoke(messages):
|
|
seen.append(messages[-1].content)
|
|
return PathSummary(verified_path="保留这一段路径及证据")
|
|
|
|
model = Mock()
|
|
model.with_structured_output.return_value.ainvoke = invoke
|
|
monkeypatch.setattr("artemis.exploration.synthesis.get_llm", Mock(return_value=model))
|
|
await _complete_path(
|
|
SimpleNamespace(task_control=None),
|
|
[{"step_number": i, "summary": "状态" * 200} for i in range(250)],
|
|
)
|
|
assert '"step_number": 0,' in seen[0]
|
|
assert '"step_number": 249,' in seen[-1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expired_generation_cannot_save_after_recovery(synthesis):
|
|
repository, session, run, model = synthesis
|
|
|
|
async def generate(_):
|
|
job = repository.database.records("exploration_generation")[0]
|
|
repository.database.put("exploration_generation", job["id"], {**job, "status": "failed"})
|
|
return CaseDraft(name="过期生成", goal="不应写入")
|
|
|
|
model.with_structured_output.return_value.ainvoke = generate
|
|
with pytest.raises(ExplorationConflict):
|
|
await generate_case(
|
|
repository,
|
|
session["id"],
|
|
"generate",
|
|
run["id"],
|
|
0,
|
|
ctx=SimpleNamespace(task_control=None),
|
|
)
|
|
assert repository.case(session["id"]) is None
|