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"]