feat(tasks): define investigation report contract and history
This commit is contained in:
@@ -29,6 +29,10 @@ def test_dynamic_investigation_routes_are_in_the_public_contract():
|
||||
assert "post" in paths["/api/v1/operational-tasks/{task_id}/access-requests/{access_request_id}/decision"]
|
||||
assert "patch" in paths["/api/v1/operational-tasks/{task_id}/actions/{action_id}/draft"]
|
||||
assert "post" in paths["/api/v1/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch"]
|
||||
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports"]
|
||||
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports/{revision}"]
|
||||
context_params = paths["/api/v1/operational-tasks/{task_id}/agent-context"]["get"]["parameters"]
|
||||
assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -104,7 +104,7 @@ async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back(
|
||||
{"id": "sample", "title": "现场采样", "owner": "human", "status": "waiting",
|
||||
"detail": "等待外部反馈", "evidence_refs": []}],
|
||||
"edges": [{"from": "review", "to": "sample"}]},
|
||||
"report": {"title": "调查阶段结论", "current_conclusion": "待现场核验", "conclusion_boundary": "合成输入",
|
||||
"report": {"stage": "final", "outcome": "concluded", "stop_reason": None, "data_gaps": [], "title": "调查阶段结论", "current_conclusion": "待现场核验", "conclusion_boundary": "合成输入",
|
||||
"evidence_refs": ["synthetic-1"], "pending_items": ["现场采样"]},
|
||||
})
|
||||
published = await service.publish_artifacts(task.task_id, artifact)
|
||||
@@ -120,7 +120,7 @@ async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back(
|
||||
source_session_id="test-investigation-session",
|
||||
),
|
||||
)
|
||||
assert task.status == "waiting_human"
|
||||
assert task.status == "completed"
|
||||
action = await service.propose_action(task.task_id, ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="需要现场采样", evidence_refs=["synthetic-1"],
|
||||
payload={"title": "采样", "description": "合成核验", "work_order_type": "sampling"},
|
||||
@@ -135,13 +135,18 @@ async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back(
|
||||
item for item in member_notifications
|
||||
if item.kind == "action_approval_pending"
|
||||
)
|
||||
human_input = next(
|
||||
report_notice = next(
|
||||
item for item in member_notifications
|
||||
if item.kind == "human_input_required"
|
||||
if item.kind == "report_updated"
|
||||
)
|
||||
assert not any(item.kind in {
|
||||
"task_created", "report_updated", "task_completed"
|
||||
"task_created", "human_input_required", "task_completed"
|
||||
} for item in member_notifications)
|
||||
assert report_notice.report_reference.revision == 1
|
||||
assert report_notice.report_reference.execution_generation == 1
|
||||
history, next_offset = await service.list_reports(task.task_id, 20, 0)
|
||||
assert next_offset is None and history[0].revision == 1
|
||||
assert (await service.report(task.task_id, 1)).report.model_dump() == artifact.report.model_dump()
|
||||
assert action_pending.required_permission == action.required_approval_permission
|
||||
|
||||
viewer_context = ProjectContext(
|
||||
@@ -158,7 +163,7 @@ async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back(
|
||||
viewer_notifications, _ = await viewer_service.list_notifications(
|
||||
unread_only=True, limit=100, offset=0
|
||||
)
|
||||
assert human_input.notification_id in {
|
||||
assert report_notice.notification_id in {
|
||||
item.notification_id for item in viewer_notifications
|
||||
}
|
||||
assert action_pending.notification_id not in {
|
||||
@@ -166,9 +171,9 @@ async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back(
|
||||
}
|
||||
|
||||
acknowledged = await service.acknowledge_notification(
|
||||
human_input.notification_id,
|
||||
report_notice.notification_id,
|
||||
NotificationAcknowledgeCommand(
|
||||
request_id=uuid4(), expected_version=human_input.version
|
||||
request_id=uuid4(), expected_version=report_notice.version
|
||||
),
|
||||
)
|
||||
assert acknowledged.status == "read"
|
||||
@@ -184,21 +189,21 @@ async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back(
|
||||
)
|
||||
persisted_created = next(
|
||||
item for item in persisted_notifications
|
||||
if item.notification_id == human_input.notification_id
|
||||
if item.notification_id == report_notice.notification_id
|
||||
)
|
||||
assert persisted_created.status == "read"
|
||||
assert persisted_created.version == human_input.version + 1
|
||||
assert persisted_created.version == report_notice.version + 1
|
||||
assert persisted_created.read_by == f"principal:{context.user_id}"
|
||||
unread_notifications, _ = await persisted_service.list_notifications(
|
||||
unread_only=True, limit=100, offset=0
|
||||
)
|
||||
assert human_input.notification_id not in {
|
||||
assert report_notice.notification_id not in {
|
||||
item.notification_id for item in unread_notifications
|
||||
}
|
||||
viewer_after_member_ack, _ = await viewer_service.list_notifications(
|
||||
unread_only=True, limit=100, offset=0
|
||||
)
|
||||
assert human_input.notification_id in {
|
||||
assert report_notice.notification_id in {
|
||||
item.notification_id for item in viewer_after_member_ack
|
||||
}
|
||||
action = await service.decide_action(task.task_id, action.action_id, ActionDecisionCommand(
|
||||
@@ -259,6 +264,10 @@ async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back(
|
||||
context, permissions_for_context(context))
|
||||
with pytest.raises(DomainError):
|
||||
await outsider.detail(task.task_id)
|
||||
await fresh_service.publish_artifacts(task.task_id, artifact.model_copy(update={
|
||||
"request_id": uuid4(), "run_id": resumed.active_run.run_id,
|
||||
"source_session_id": "test-investigation-followup-session",
|
||||
}))
|
||||
sampling = await fresh_service.propose_action(task.task_id, ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="需要补充采样",
|
||||
evidence_refs=["synthetic-1"],
|
||||
@@ -307,5 +316,35 @@ async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back(
|
||||
dispatcher, "Bearer TEST_ONLY")
|
||||
assert calls == ["POST", "GET", "GET"]
|
||||
assert (await fresh_service.detail(task.task_id)).status == "queued"
|
||||
# A separate investigation ends normally on an evidenced empty query.
|
||||
missing = await service.create(command.model_copy(update={
|
||||
"request_id": uuid4(), "title": "AUTOTEST 缺数退出",
|
||||
}))
|
||||
missing_run = await service.claim_run(missing.task_id, missing.active_run.run_id, AgentRunCommand(
|
||||
request_id=missing.active_run.execution_request_id,
|
||||
expected_revision=missing.active_run.revision, source_session_id="missing-data-session"))
|
||||
exit_artifact = AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="missing-data-session", run_id=missing_run.run_id,
|
||||
evidence=[{"evidence_id": "empty-sample", "kind": "synthetic", "summary": "查询成功,无采样记录",
|
||||
"source_label": "合成空查询", "observed_at": now.isoformat()}],
|
||||
plan={"schema": "execution-plan@1", "title": "调查", "reason": "关键数据不足",
|
||||
"nodes": [{"id": "query", "title": "核对采样", "owner": "agent", "status": "completed",
|
||||
"detail": "查询范围内无记录", "evidence_refs": ["empty-sample"]}]},
|
||||
report={"title": "无法形成结论", "current_conclusion": "无法确定投诉原因",
|
||||
"conclusion_boundary": "缺少末梢采样", "evidence_refs": ["empty-sample"],
|
||||
"stage": "final", "outcome": "insufficient_data", "stop_reason": "关键采样数据为空",
|
||||
"data_gaps": [{"gap_id": "sample", "node_id": "query", "missing_data": "末梢采样",
|
||||
"scope": "投诉时段", "impact": "无法判断异常来源", "required_data": "末梢采样结果",
|
||||
"evidence_refs": ["empty-sample"], "blocking": True}]})
|
||||
exited = await service.publish_artifacts(missing.task_id, exit_artifact)
|
||||
await service.complete_run(missing.task_id, missing_run.run_id, AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=exited.task.active_run.revision,
|
||||
source_session_id="missing-data-session"))
|
||||
restored = await fresh_service.detail(missing.task_id)
|
||||
assert restored.lifecycle_status == "completed"
|
||||
assert restored.active_run is None
|
||||
assert restored.latest_report.report.outcome == "insufficient_data"
|
||||
assert restored.latest_report.report.data_gaps[0].blocking is True
|
||||
assert not restored.actions
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -53,8 +53,12 @@ async def test_dry_run_validates_historical_schema_before_any_network_access(tmp
|
||||
args = argparse.Namespace(session_id="ses_test", agent_data=tmp_path, dry_run=True)
|
||||
await run(args)
|
||||
assert "2 nodes and 1 synthetic evidence records; no writes" in capsys.readouterr().out
|
||||
args.dry_run = False
|
||||
with pytest.raises(ValidationError):
|
||||
await run(args)
|
||||
args.dry_run = True
|
||||
state["nodes"][1]["status"] = "invented_status"
|
||||
state_path.write_text(json.dumps({"executionPlans": [state]}))
|
||||
with pytest.raises(ValidationError):
|
||||
await run(args)
|
||||
assert "Ready" not in capsys.readouterr().out
|
||||
assert "Validated" not in capsys.readouterr().out
|
||||
|
||||
@@ -297,6 +297,12 @@ class TaskRepository:
|
||||
async def get_latest_report(self, _task_id):
|
||||
return self.reports[-1] if self.reports else None
|
||||
|
||||
async def get_report(self, _task_id, revision):
|
||||
return next((item for item in self.reports if item["revision"] == revision), None)
|
||||
|
||||
async def list_reports(self, _task_id, limit, offset):
|
||||
return list(reversed(self.reports))[offset:offset + limit]
|
||||
|
||||
async def insert_plan(self, _task_id, snapshot):
|
||||
self.plans.append(snapshot)
|
||||
|
||||
@@ -504,6 +510,7 @@ def _waiting_plan(action_id=None):
|
||||
|
||||
def _current_report():
|
||||
return {
|
||||
"stage": "final", "outcome": "concluded", "stop_reason": None, "data_gaps": [],
|
||||
"title": "本代调查报告",
|
||||
"current_conclusion": "等待现场处置",
|
||||
"conclusion_boundary": "尚无现场回执",
|
||||
@@ -513,6 +520,125 @@ def _current_report():
|
||||
}
|
||||
|
||||
|
||||
def _insufficient_report():
|
||||
return {
|
||||
**_current_report(), "outcome": "insufficient_data",
|
||||
"current_conclusion": "无法确定投诉原因", "stop_reason": "缺少投诉时段的末梢采样",
|
||||
"recommendations": [], "pending_items": [], "evidence_refs": ["empty-query"],
|
||||
"data_gaps": [{"gap_id": "sample-gap", "node_id": "read", "missing_data": "末梢采样",
|
||||
"scope": "投诉小区,2026-09-23 08:00 至 09:00", "impact": "无法判断异常来源",
|
||||
"required_data": "补齐该时段采样结果", "evidence_refs": ["empty-query"], "blocking": True}],
|
||||
}
|
||||
|
||||
|
||||
def test_report_contract_separates_legacy_reads_from_new_writes():
|
||||
from app.domain.schemas.operational_task import InvestigationReport, InvestigationReportInput
|
||||
legacy = {k: v for k, v in _current_report().items()
|
||||
if k not in {"stage", "outcome", "stop_reason", "data_gaps"}}
|
||||
stored = InvestigationReport.model_validate(legacy)
|
||||
assert stored.outcome is None and stored.data_gaps is None
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationReportInput.model_validate(legacy)
|
||||
assert InvestigationReportInput.model_validate(_insufficient_report()).outcome == "insufficient_data"
|
||||
for changes in [{"stage": "interim"}, {"stop_reason": None}, {"data_gaps": []},
|
||||
{"recommendations": ["自动派采样工单"]}, {"pending_items": ["等待人工"]},
|
||||
{"outcome": "concluded"}, {"outcome": "other"}]:
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationReportInput.model_validate({**_insufficient_report(), **changes})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_insufficient_data_exits_successfully_and_blocks_actions_until_new_run():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
command = AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1",
|
||||
evidence=[{"evidence_id": "empty-query", "kind": "synthetic", "summary": "查询成功,该范围无采样记录",
|
||||
"source_label": "合成查询", "observed_at": now.isoformat()}],
|
||||
plan={"schema": "execution-plan@1", "title": "调查", "reason": "关键数据不足",
|
||||
"nodes": [{"id": "read", "title": "查询采样", "owner": "agent", "status": "completed",
|
||||
"detail": "已核对查询范围,无记录", "evidence_refs": ["empty-query"]},
|
||||
{"id": "action", "title": "处置建议", "owner": "agent", "status": "skipped",
|
||||
"detail": "缺少关键数据,未进入"}],
|
||||
"edges": [{"from": "read", "to": "action"}]},
|
||||
report=_insufficient_report(),
|
||||
)
|
||||
published = await service.publish_artifacts(task["task_id"], command)
|
||||
replay = await service.publish_artifacts(task["task_id"], command)
|
||||
assert published.report_revision == replay.report_revision == 1
|
||||
with pytest.raises(DomainError) as ended:
|
||||
await service.publish_artifacts(task["task_id"], command.model_copy(update={"request_id": uuid4()}))
|
||||
assert ended.value.code == "INVESTIGATION_ENDED"
|
||||
with pytest.raises(DomainError) as exc:
|
||||
await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="缺数后不应派单",
|
||||
payload={"title": "采样", "description": "采样", "work_order_type": "sampling"},
|
||||
))
|
||||
assert exc.value.code == "ACTION_CONCLUSION_REQUIRED"
|
||||
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||
assert completed.lifecycle_status == "completed"
|
||||
assert repository.run["status"] == "succeeded"
|
||||
assert completed.latest_report.report.outcome == "insufficient_data"
|
||||
assert repository.action is None
|
||||
assert not any(item["kind"] in {"task_failed", "human_input_required", "action_approval_pending"}
|
||||
for item in repository.notifications)
|
||||
from app.domain.schemas.operational_task import OperationalTaskRetryCommand
|
||||
restarted = await service.retry_task(task["task_id"], OperationalTaskRetryCommand(
|
||||
request_id=uuid4(), reason="已补充新的采样记录"))
|
||||
assert restarted.execution_generation == 2
|
||||
assert restarted.latest_report.execution_generation == 1
|
||||
assert restarted.latest_report.report.outcome == "insufficient_data"
|
||||
with pytest.raises(DomainError) as stale:
|
||||
await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="新轮次尚未形成结论",
|
||||
payload={"title": "采样", "description": "采样", "work_order_type": "sampling"}))
|
||||
assert stale.value.code == "ACTION_CONCLUSION_REQUIRED"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("defect,expected", [
|
||||
("unlisted_evidence", "REPORT_GAP_EVIDENCE_INVALID"), ("pending_action", "REPORT_ACTION_PENDING"),
|
||||
])
|
||||
async def test_insufficient_report_rejects_inconsistent_context(defect, expected):
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
report = _insufficient_report()
|
||||
node = {"id": "read", "title": "查询", "owner": "agent", "status": "skipped", "detail": "关键数据不足"}
|
||||
if defect == "unknown_node": report["data_gaps"][0]["node_id"] = "unknown"
|
||||
if defect == "unlisted_evidence": report["data_gaps"][0]["evidence_refs"] = ["unlisted"]
|
||||
if defect == "waiting_node": node["status"] = "waiting"
|
||||
if defect == "pending_action":
|
||||
_seed_concluded_report(repository, now)
|
||||
await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="已产生待审动作",
|
||||
payload={"title": "采样", "description": "采样", "work_order_type": "sampling"}))
|
||||
with pytest.raises(DomainError) as exc:
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1",
|
||||
evidence=[{"evidence_id": "empty-query", "kind": "synthetic", "summary": "查询空结果",
|
||||
"source_label": "合成查询", "observed_at": now.isoformat()}],
|
||||
plan={"schema": "execution-plan@1", "title": "调查", "reason": "无数据", "nodes": [node]},
|
||||
report=report))
|
||||
assert exc.value.code == expected
|
||||
|
||||
|
||||
def _seed_concluded_report(repository, now):
|
||||
if repository.reports and repository.reports[-1].get("execution_generation") == repository.task["execution_generation"]:
|
||||
return
|
||||
repository.reports.append({
|
||||
"revision": 1, "execution_generation": repository.task["execution_generation"],
|
||||
"source_session_id": "session-1", "published_at": now.isoformat(),
|
||||
"published_by": "agent", "report": _current_report(),
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_plan_only_keeps_lease_until_same_generation_report_closes_run():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
@@ -541,15 +667,40 @@ async def test_plan_only_keeps_lease_until_same_generation_report_closes_run():
|
||||
source_session_id="session-1",
|
||||
),
|
||||
)
|
||||
assert completed.status == "waiting_human"
|
||||
assert completed.status == "completed"
|
||||
assert completed.agent_lease_expires_at is None
|
||||
assert [item["kind"] for item in repository.notifications] == [
|
||||
"human_input_required"
|
||||
"report_updated"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_report_does_not_close_from_legacy_or_prior_generation_plan():
|
||||
async def test_interim_report_cannot_finish_even_with_a_completed_plan():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
plan = _waiting_plan()
|
||||
plan["nodes"][0]["status"] = "completed"
|
||||
plan["nodes"][0]["evidence_refs"] = ["field-result"]
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", plan=plan,
|
||||
evidence=[{"evidence_id": "field-result", "kind": "synthetic", "summary": "模拟现场结果",
|
||||
"source_label": "测试采样", "observed_at": now.isoformat()}],
|
||||
report={**_current_report(), "stage": "interim"},
|
||||
))
|
||||
|
||||
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
|
||||
))
|
||||
|
||||
assert completed.lifecycle_status == "failed"
|
||||
assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_report_is_not_blocked_by_a_historical_plan():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
@@ -568,9 +719,109 @@ async def test_report_does_not_close_from_legacy_or_prior_generation_plan():
|
||||
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
||||
))
|
||||
|
||||
assert result.task.status == "running"
|
||||
assert result.task.latest_report.execution_generation == 2
|
||||
assert result.task.status == "completed"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_report_and_action_without_plan_finish_with_real_approval_pending():
|
||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
||||
))
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", node_id="display-only",
|
||||
reason="已形成判断,需要现场核查", payload={"title": "现场核查", "description": "核验", "work_order_type": "general"},
|
||||
))
|
||||
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
|
||||
))
|
||||
assert completed.latest_plan is None
|
||||
assert completed.lifecycle_status == "waiting_human"
|
||||
assert completed.actions[0].action_id == action.action_id
|
||||
assert repository.run["status"] == "succeeded"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_insufficient_data_without_graph_or_node_ends_the_round():
|
||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
report = _insufficient_report()
|
||||
report["data_gaps"][0].pop("node_id")
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report=report,
|
||||
evidence=[{"evidence_id": "empty-query", "kind": "synthetic", "summary": "成功查询,范围内没有记录",
|
||||
"source_label": "测试输入", "observed_at": now.isoformat()}],
|
||||
))
|
||||
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
|
||||
))
|
||||
assert completed.lifecycle_status == "completed"
|
||||
assert completed.latest_report.report.data_gaps[0].node_id is None
|
||||
assert repository.run["status"] == "succeeded"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_report_history_and_notifications_keep_selected_and_current_references_separate():
|
||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
for conclusion in ["第一次判断", "更新后的判断"]:
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1",
|
||||
report={**_current_report(), "current_conclusion": conclusion},
|
||||
))
|
||||
page, offset = await service.list_reports(task["task_id"], 1, 0)
|
||||
assert [item.revision for item in page] == [2] and offset == 1
|
||||
page, offset = await service.list_reports(task["task_id"], 1, offset)
|
||||
assert [item.revision for item in page] == [1] and offset is None
|
||||
selected = await service.agent_context(task["task_id"], report_revision=1)
|
||||
assert selected.selected_report.revision == 1
|
||||
assert selected.task.latest_report.revision == 2
|
||||
assert selected.selected_report.report.current_conclusion == "第一次判断"
|
||||
assert repository.notifications[0]["lifecycle_status"] == "resolved"
|
||||
assert repository.notifications[-1]["report_reference"] == {"execution_generation": 1, "revision": 2}
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1",
|
||||
report={**_current_report(), "current_conclusion": "更新后的判断",
|
||||
"diagram": {"title": "说明", "mermaid": 'flowchart TD\nA["核查"] --> B["判断"]'}},
|
||||
))
|
||||
assert len(repository.notifications) == 2
|
||||
with pytest.raises(DomainError) as missing:
|
||||
await service.report(task["task_id"], 999)
|
||||
assert missing.value.code == "REPORT_NOT_FOUND"
|
||||
with pytest.raises(DomainError) as other_task:
|
||||
await service.list_reports(uuid4(), 20, 0)
|
||||
assert other_task.value.status == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("node_ref,code", [
|
||||
({"node_id": "A", "label": "依据", "evidence_refs": ["forged"]}, "EVIDENCE_NOT_FOUND"),
|
||||
({"node_id": "A", "label": "动作", "action_refs": [str(uuid4())]}, "ACTION_NOT_FOUND"),
|
||||
])
|
||||
async def test_optional_diagram_cannot_forge_business_references(node_ref, code):
|
||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
with pytest.raises(DomainError) as invalid:
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1",
|
||||
report={**_current_report(), "diagram": {"title": "调查脉络", "mermaid": 'flowchart TD\nA["核查"]', "node_refs": [node_ref]}},
|
||||
))
|
||||
assert invalid.value.code == code
|
||||
assert repository.reports == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -586,6 +837,7 @@ async def test_action_proposal_keeps_running_lease_until_plan_and_report_close_r
|
||||
revision_before_proposal = repository.run["revision"]
|
||||
lease_before_proposal = repository.run["lease_expires_at"]
|
||||
|
||||
_seed_concluded_report(repository, now)
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
||||
node_id="field-work", evidence_refs=[],
|
||||
@@ -633,6 +885,7 @@ async def test_rejected_action_requeues_task_in_a_new_execution_generation():
|
||||
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
||||
clock=lambda: now,
|
||||
)
|
||||
_seed_concluded_report(repository, now)
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
||||
evidence_refs=[],
|
||||
@@ -681,6 +934,7 @@ async def test_action_decision_rejects_a_stale_expected_version():
|
||||
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
||||
clock=lambda: now,
|
||||
)
|
||||
_seed_concluded_report(repository, now)
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
||||
evidence_refs=[],
|
||||
@@ -847,6 +1101,7 @@ async def test_work_order_draft_edit_creates_new_review_version():
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, clock=lambda: now)
|
||||
_seed_concluded_report(repository, now)
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="现场采样", evidence_refs=[],
|
||||
payload={"title": "旧草稿", "description": "采样", "work_order_type": "general"},
|
||||
@@ -874,6 +1129,7 @@ async def test_one_click_work_order_confirmation_replays_without_duplicate_dispa
|
||||
service = OperationalTaskService(repository, context, {
|
||||
OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||
}, clock=lambda: now)
|
||||
_seed_concluded_report(repository, now)
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="现场采样", evidence_refs=[],
|
||||
payload={"title": "采样", "description": "现场核验", "work_order_type": "general"},
|
||||
@@ -914,6 +1170,7 @@ async def test_new_payload_creates_a_new_pending_action_without_inheriting_appro
|
||||
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
||||
clock=lambda: now,
|
||||
)
|
||||
_seed_concluded_report(repository, now)
|
||||
original = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="需要首次核验",
|
||||
evidence_refs=[],
|
||||
@@ -927,6 +1184,7 @@ async def test_new_payload_creates_a_new_pending_action_without_inheriting_appro
|
||||
),
|
||||
)
|
||||
|
||||
_seed_concluded_report(repository, now)
|
||||
revised = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="内容已修改",
|
||||
evidence_refs=[],
|
||||
@@ -1057,6 +1315,7 @@ async def test_withdrawn_external_task_rejects_action_changes_and_resolves_appro
|
||||
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
||||
clock=lambda: now,
|
||||
)
|
||||
_seed_concluded_report(repository, now)
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
||||
evidence_refs=[],
|
||||
|
||||
Reference in New Issue
Block a user