180 lines
9.1 KiB
Python
180 lines
9.1 KiB
Python
from copy import deepcopy
|
|
from datetime import datetime, timedelta, timezone
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
|
|
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE
|
|
from app.domain.domain_errors import DomainError
|
|
from app.domain.schemas.operational_task import AgentRunCommand, AgentRunFailureCommand, OperationalTaskRetryCommand
|
|
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply
|
|
from app.services.operational_tasks import OperationalTaskService
|
|
from app.services.task_questions import create_question, answer_question
|
|
from tests.unit.test_operational_tasks import RunTaskRepository, _context, _task_document, _active_run
|
|
|
|
|
|
@pytest.fixture
|
|
def anyio_backend():
|
|
return "asyncio"
|
|
|
|
|
|
class QuestionRepository(RunTaskRepository):
|
|
def __init__(self, task, run):
|
|
super().__init__(task, run)
|
|
self.questions = {}
|
|
|
|
async def list_questions(self, task_id):
|
|
return [deepcopy(q) for q in self.questions.values() if q["task_id"] == task_id]
|
|
|
|
async def get_question(self, task_id, question_id):
|
|
q = self.questions.get(question_id)
|
|
return deepcopy(q) if q and q["task_id"] == task_id else None
|
|
|
|
async def save_question(self, task_id, document):
|
|
assert document["task_id"] == task_id
|
|
self.questions[document["question_request_id"]] = deepcopy(document)
|
|
|
|
|
|
def setup():
|
|
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
|
|
context = _context()
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
repo = QuestionRepository(task, _active_run(task, now))
|
|
service = OperationalTaskService(repo, context, {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}, clock=lambda: now)
|
|
command = TaskQuestionCreate(request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"],
|
|
questions=[dict(question_id="flow", title="漏水量依据", body="是否有计量记录?", options=[{"id": "unknown", "label": "目前未知"}],
|
|
mode="single", allow_custom=True, required=True)])
|
|
return service, repo, UUID(task["task_id"]), command
|
|
|
|
|
|
async def finish(service, repo, task_id):
|
|
return await service.complete_run(task_id, UUID(repo.run["run_id"]),
|
|
AgentRunCommand(request_id=uuid4(), expected_revision=repo.run["revision"], source_session_id="session-1"))
|
|
|
|
|
|
def reply(**overrides):
|
|
return TaskQuestionReply(request_id=uuid4(), expected_version=1,
|
|
answers=[{"question_id": "flow", "selected_option_ids": ["unknown"], "custom_text": "需要采用情景假设"}], **overrides)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_question_waits_without_final_report_and_answer_requeues_once_without_fabricating_evidence():
|
|
service, repo, task_id, command = setup()
|
|
question = await create_question(service, task_id, command)
|
|
assert (await create_question(service, task_id, command)).question_request_id == question.question_request_id
|
|
assert len(repo.notifications) == 1
|
|
with pytest.raises(DomainError, match="waiting_human"):
|
|
await answer_question(service, task_id, question.question_request_id, reply())
|
|
waiting = await finish(service, repo, task_id)
|
|
assert waiting.lifecycle_status == "waiting_human" and waiting.active_run is None
|
|
assert repo.run["status"] == "succeeded" and repo.run["failure_code"] is None
|
|
assert len(repo.notifications) == 1 and repo.notifications[0]["subject_type"] == "question"
|
|
answer = reply()
|
|
resumed = await answer_question(service, task_id, question.question_request_id, answer)
|
|
replay = await answer_question(service, task_id, question.question_request_id, answer)
|
|
assert resumed.execution_generation == replay.execution_generation == 2
|
|
assert resumed.active_run.status == "queued"
|
|
assert resumed.questions[0].answers[0].custom_text == "需要采用情景假设"
|
|
assert resumed.questions[0].answered_by == service._actor()
|
|
assert not repo.evidence and not resumed.actions
|
|
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("answers", [[],
|
|
[{"question_id": "foreign", "selected_option_ids": [], "custom_text": "x"}],
|
|
[{"question_id": "flow", "selected_option_ids": ["bogus"], "custom_text": ""}],
|
|
[{"question_id": "flow", "selected_option_ids": ["unknown", "unknown"], "custom_text": ""}],
|
|
])
|
|
async def test_invalid_answers_do_not_resume_or_resolve_notification(answers):
|
|
service, repo, task_id, command = setup()
|
|
question = await create_question(service, task_id, command)
|
|
await finish(service, repo, task_id)
|
|
with pytest.raises(DomainError) as caught:
|
|
await answer_question(service, task_id, question.question_request_id,
|
|
TaskQuestionReply(request_id=uuid4(), expected_version=1, answers=answers))
|
|
assert caught.value.code == "ANSWER_INVALID"
|
|
assert repo.task["execution_generation"] == 1
|
|
assert repo.notifications[0]["lifecycle_status"] == "active"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_permissions_foreign_session_duplicate_pending_and_stale_generation():
|
|
service, repo, task_id, command = setup()
|
|
service.permissions = frozenset()
|
|
with pytest.raises(DomainError) as caught:
|
|
await create_question(service, task_id, command)
|
|
assert caught.value.code == "PERMISSION_DENIED"
|
|
service.permissions = {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}
|
|
with pytest.raises(DomainError) as caught:
|
|
await create_question(service, task_id, command.model_copy(update={"source_session_id": "foreign"}))
|
|
assert caught.value.code == "SESSION_MISMATCH"
|
|
question = await create_question(service, task_id, command)
|
|
with pytest.raises(DomainError) as caught:
|
|
await create_question(service, task_id, command.model_copy(update={"request_id": uuid4()}))
|
|
assert caught.value.code == "QUESTION_PENDING"
|
|
await finish(service, repo, task_id)
|
|
await service.retry_task(task_id, OperationalTaskRetryCommand(request_id=uuid4(), reason="重新调查"))
|
|
with pytest.raises(DomainError) as caught:
|
|
await answer_question(service, task_id, question.question_request_id, reply())
|
|
assert caught.value.code == "VERSION_CONFLICT"
|
|
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
|
|
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_pending_calculations_must_finish_before_asking():
|
|
service, repo, task_id, command = setup()
|
|
async def pending(*_args):
|
|
return [str(uuid4())]
|
|
repo.pending_calculation_jobs = pending
|
|
with pytest.raises(DomainError) as caught:
|
|
await create_question(service, task_id, command)
|
|
assert caught.value.code == "PENDING_CALCULATIONS"
|
|
assert not repo.questions and not repo.notifications
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_answer_requires_write_permission_and_matching_version():
|
|
service, repo, task_id, command = setup()
|
|
question = await create_question(service, task_id, command)
|
|
await finish(service, repo, task_id)
|
|
service.permissions = {OPERATIONAL_TASK_EXECUTE}
|
|
with pytest.raises(DomainError) as caught:
|
|
await answer_question(service, task_id, question.question_request_id, reply())
|
|
assert caught.value.code == "PERMISSION_DENIED"
|
|
service.permissions = {OPERATIONAL_TASK_WRITE}
|
|
with pytest.raises(DomainError) as caught:
|
|
await answer_question(service, task_id, question.question_request_id, reply().model_copy(update={"expected_version": 9}))
|
|
assert caught.value.code == "VERSION_CONFLICT"
|
|
assert repo.task["execution_generation"] == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_real_execution_failure_cancels_question_instead_of_disguising_failure():
|
|
service, repo, task_id, command = setup()
|
|
question = await create_question(service, task_id, command)
|
|
result = await service.fail_run(task_id, UUID(repo.run["run_id"]), AgentRunFailureCommand(
|
|
request_id=uuid4(), expected_revision=2, source_session_id="session-1",
|
|
failure_code="SOURCE_UNAVAILABLE", failure_detail="Required tool failed"))
|
|
assert result.lifecycle_status == "failed"
|
|
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
|
|
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_multiple_selection_and_optional_question_are_preserved():
|
|
service, repo, task_id, command = setup()
|
|
question_data = command.questions[0].model_dump()
|
|
question_data.update(mode="multiple", allow_custom=False,
|
|
options=[{"id": "in", "label": "入口计量"}, {"id": "out", "label": "出口计量"}])
|
|
command = TaskQuestionCreate(**{**command.model_dump(), "questions": [question_data,
|
|
{**question_data, "question_id": "optional", "required": False}]})
|
|
question = await create_question(service, task_id, command)
|
|
await finish(service, repo, task_id)
|
|
result = await answer_question(service, task_id, question.question_request_id, TaskQuestionReply(
|
|
request_id=uuid4(), expected_version=1,
|
|
answers=[{"question_id": "flow", "selected_option_ids": ["in", "out"], "custom_text": ""}]))
|
|
assert result.execution_generation == 2
|
|
assert result.questions[0].answers[0].selected_option_ids == ["in", "out"]
|