192 lines
5.6 KiB
Python
192 lines
5.6 KiB
Python
"""Domain contracts aligned with the frontend frozen types."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from app.report_collaboration.contracts import (
|
|
ALL_COLLABORATION_EVENT_TYPES,
|
|
CollaborationEventEnvelope,
|
|
CollaborationMessage,
|
|
IntentDecision,
|
|
NodeContractDetail,
|
|
ReportRequirementSnapshot,
|
|
SessionSnapshot,
|
|
make_node_run_id,
|
|
parse_node_run_id,
|
|
)
|
|
|
|
FRONTEND_EVENT_TYPES = {
|
|
"session.snapshot",
|
|
"message.created",
|
|
"message.delta",
|
|
"message.completed",
|
|
"tool_call.created",
|
|
"tool_call.delta",
|
|
"tool_call.completed",
|
|
"tool_result.created",
|
|
"tool_result.delta",
|
|
"tool_result.completed",
|
|
"team_message.created",
|
|
"team_message.delta",
|
|
"team_message.completed",
|
|
"requirement.updated",
|
|
"clarification.requested",
|
|
"clarification.resolved",
|
|
"plan.progress",
|
|
"plan.proposed",
|
|
"plan.selected",
|
|
"run.status.changed",
|
|
"node.created",
|
|
"node.status.changed",
|
|
"node.progress",
|
|
"node.attempt.created",
|
|
"artifact.created",
|
|
"artifact.validated",
|
|
"artifact.rejected",
|
|
"source.added",
|
|
"command.classified",
|
|
"command.confirmation.required",
|
|
"command.accepted",
|
|
"command.completed",
|
|
"command.failed",
|
|
"command.cancelled",
|
|
"report.delta",
|
|
"report.version.created",
|
|
"review.created",
|
|
"error",
|
|
"heartbeat",
|
|
}
|
|
|
|
|
|
def test_event_types_match_frontend_frozen_set() -> None:
|
|
assert set(ALL_COLLABORATION_EVENT_TYPES) == FRONTEND_EVENT_TYPES
|
|
assert "agent_run.started" not in ALL_COLLABORATION_EVENT_TYPES
|
|
|
|
|
|
def test_event_envelope_serializes_camel_case() -> None:
|
|
envelope = CollaborationEventEnvelope(
|
|
eventId="evt_1",
|
|
sessionId="session_1",
|
|
runId="run_1",
|
|
seq=1,
|
|
type="message.created",
|
|
timestamp="2026-09-05T04:00:00Z",
|
|
data={"message": {"message_id": "msg_1"}},
|
|
)
|
|
dumped = envelope.to_sse_dict()
|
|
assert dumped["eventId"] == "evt_1"
|
|
assert dumped["sessionId"] == "session_1"
|
|
assert dumped["runId"] == "run_1"
|
|
assert dumped["seq"] == 1
|
|
assert dumped["type"] == "message.created"
|
|
assert dumped["data"]["message"]["message_id"] == "msg_1"
|
|
|
|
|
|
def test_event_envelope_accepts_snake_or_camel_aliases() -> None:
|
|
envelope = CollaborationEventEnvelope.model_validate(
|
|
{
|
|
"event_id": "evt_2",
|
|
"session_id": "session_1",
|
|
"run_id": None,
|
|
"seq": 2,
|
|
"type": "heartbeat",
|
|
"timestamp": "2026-09-05T04:00:01Z",
|
|
"data": {},
|
|
}
|
|
)
|
|
assert envelope.event_id == "evt_2"
|
|
assert envelope.run_id is None
|
|
|
|
|
|
def test_node_run_id_is_deterministic() -> None:
|
|
assert make_node_run_id("rcr_x", "research_brand", 2) == "rcr_x-research_brand-attempt2"
|
|
assert parse_node_run_id("rcr_x-research_brand-attempt2") == ("rcr_x-research_brand", 2)
|
|
with pytest.raises(ValueError):
|
|
make_node_run_id("rcr_x", "research_brand", 0)
|
|
with pytest.raises(ValueError):
|
|
make_node_run_id("", "research_brand", 1)
|
|
with pytest.raises(ValueError):
|
|
parse_node_run_id("random-uuid")
|
|
|
|
|
|
def test_requirement_revision_required() -> None:
|
|
with pytest.raises(ValidationError):
|
|
ReportRequirementSnapshot(topic="新能源汽车", revision=0)
|
|
|
|
|
|
def test_session_snapshot_round_trip() -> None:
|
|
snapshot = SessionSnapshot.model_validate(
|
|
{
|
|
"session": {
|
|
"id": "session_1",
|
|
"title": "新能源趋势",
|
|
"status": "empty",
|
|
"requirement_revision": 1,
|
|
"selected_plan_id": None,
|
|
"active_run_id": None,
|
|
"current_report_version_id": None,
|
|
"created_at": "2026-09-05T04:00:00Z",
|
|
"updated_at": "2026-09-05T04:00:00Z",
|
|
},
|
|
"messages": [],
|
|
"plans": [],
|
|
"run": None,
|
|
"nodes": [],
|
|
"agent_runs": [],
|
|
"commands": [],
|
|
"report_versions": [],
|
|
"last_seq": 0,
|
|
}
|
|
)
|
|
dumped = snapshot.model_dump(mode="json")
|
|
assert dumped["session"]["requirement_revision"] == 1
|
|
assert dumped["last_seq"] == 0
|
|
assert SessionSnapshot.model_validate(dumped).session.id == "session_1"
|
|
|
|
|
|
def test_node_contract_detail_has_no_execution_body() -> None:
|
|
detail = NodeContractDetail(
|
|
node_id="n1",
|
|
label="品牌研究",
|
|
role="Researcher",
|
|
angle="市场份额",
|
|
status="planned",
|
|
attempt=1,
|
|
mission="收集 2024 至今份额",
|
|
output_artifact_type="EvidenceBundle",
|
|
max_attempts=3,
|
|
)
|
|
fields = set(detail.model_dump().keys())
|
|
assert "content" not in fields
|
|
assert "markdown" not in fields
|
|
assert "artifact_body" not in fields
|
|
assert "tool_results" not in fields
|
|
assert "mission" in fields
|
|
assert "acceptance_criteria" in fields
|
|
|
|
|
|
def test_user_message_carries_idempotency_key_in_metadata() -> None:
|
|
message = CollaborationMessage(
|
|
message_id="msg_user_1",
|
|
role="human",
|
|
content="写一份新能源报告",
|
|
visibility="visible",
|
|
metadata={"idempotency_key": "idem-1"},
|
|
created_at="2026-09-05T04:00:00Z",
|
|
completed_at="2026-09-05T04:00:00Z",
|
|
)
|
|
assert message.metadata["idempotency_key"] == "idem-1"
|
|
|
|
|
|
def test_intent_decision_rejects_unknown_intent() -> None:
|
|
with pytest.raises(ValidationError):
|
|
IntentDecision(
|
|
intent="do_whatever", # type: ignore[arg-type]
|
|
confidence=0.9,
|
|
normalized_instruction="x",
|
|
scope="conversation",
|
|
reason="nope",
|
|
)
|