Files
X-Financial/server/tests/test_orchestrator_service.py

1770 lines
63 KiB
Python
Raw Normal View History

from __future__ import annotations
import json
from collections.abc import Generator
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.api.deps import get_db
from app.core.config import get_settings
from app.db.base import Base
from app.main import create_app
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
from app.models.employee import Employee
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
)
from app.schemas.settings import SettingsWrite
from app.services.agent_assets import AgentAssetService
from app.services.knowledge import KnowledgeService
from app.services.settings import SettingsService
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def seed_llm_wiki_knowledge(storage_root: Path) -> str:
service = KnowledgeService(storage_root=storage_root)
detail = service.upload_document(
folder="报销制度",
filename="公司差旅制度.txt",
content=(
"差旅住宿标准:直辖市和特区住宿费最高 500 元,"
"省会城市 450 元,其他地区 400 元。"
).encode("utf-8"),
current_user=CurrentUserContext(
username="admin",
name="系统管理员",
role_codes=["manager"],
is_admin=True,
),
)
entry = service.get_document_entry(detail.id)
document_dir = storage_root / "knowledge" / ".llm_wiki" / "documents" / detail.id
document_dir.mkdir(parents=True, exist_ok=True)
knowledge_candidates = [
{
"candidate_id": "kc_travel_standard",
"title": "差旅费报销标准",
"content": (
"住宿费限额:国内直辖市或特区最高 500 元,"
"省会城市 450 元,其他地区 400 元。"
"出差补贴:国内餐补 75/65/55 元/天(按地区),"
"基本补助 35 元/天,合计 110/100/90 元/天。"
),
"domain": "expense",
"scenario": "expense_reimbursement",
"tags": ["差旅", "住宿费", "标准"],
"source_document_id": detail.id,
"source_document_name": entry["original_name"],
"source_chunk_ids": [f"{detail.id}-document"],
"evidence": ["第八条 差旅住宿标准"],
"confidence": 0.96,
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-15T10:20:55+00:00",
"extraction_mode": "hermes",
"quality_flags": [],
"fallback_reason": "",
}
]
(document_dir / "knowledge_candidates.json").write_text(
json.dumps(knowledge_candidates, ensure_ascii=False, indent=2),
encoding="utf-8",
)
(document_dir / "knowledge_summary.md").write_text(
(
"# 公司差旅制度知识总结\n\n"
"## 差旅住宿标准\n"
"- 国内直辖市和特区住宿费最高 500 元。\n"
"- 省会城市 450 元,其他地区 400 元。\n"
),
encoding="utf-8",
)
(storage_root / "knowledge" / ".llm_wiki" / "index.json").write_text(
json.dumps(
{
"documents": [
{
"document_id": detail.id,
"document_name": entry["original_name"],
"folder": entry["folder"],
"document_version": "v1.0",
"checksum": entry["sha256"],
"extracted_text_path": str(document_dir / "text.md"),
"chunk_count": 1,
"candidate_chunk_count": 1,
"filtered_chunk_count": 0,
"group_count": 1,
"successful_group_count": 1,
"failed_group_count": 0,
"knowledge_candidate_count": 1,
"formal_knowledge_candidate_count": 1,
"fallback_knowledge_candidate_count": 0,
"rule_candidate_count": 0,
"quality_status": "formal",
"quality_note": "Hermes 已基于完整原文件完成正式归纳。",
"updated_at": "2026-05-15T10:20:56+00:00",
"signature": {
"document_id": entry["id"],
"original_name": entry["original_name"],
"stored_name": entry["stored_name"],
"sha256": entry["sha256"],
"version_number": int(entry["version_number"]),
"updated_at": entry["updated_at"],
},
}
]
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
return detail.id
def test_orchestrator_routes_user_query_to_user_agent() -> None:
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "客户A这个月还有多少应收",
"context_json": {"role_codes": ["finance"]},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["selected_agent"] == "user_agent"
assert payload["permission_level"] == "read"
assert payload["status"] == "succeeded"
assert payload["conversation_id"]
assert payload["result"]["answer"]
assert payload["result"]["suggested_actions"]
assert payload["trace_summary"]["tool_count"] >= 1
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
assert run_detail["agent"] == "user_agent"
assert run_detail["route_json"]["selected_agent"] == "user_agent"
assert run_detail["semantic_parse"]["scenario"] == "accounts_receivable"
assert run_detail["tool_calls"][0]["tool_type"] == "database"
def test_orchestrator_answers_knowledge_question_from_llm_wiki(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
document_id = seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "差旅住宿标准按什么规则执行?",
"context_json": {"role_codes": ["employee"], "name": "测试用户"},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["selected_agent"] == "user_agent"
assert payload["status"] == "succeeded"
assert "差旅费报销标准" in payload["result"]["answer"]
assert payload["result"]["citations"][0]["source_type"] == "knowledge"
assert payload["result"]["citations"][0]["title"] == "差旅费报销标准"
assert "500 元" in payload["result"]["citations"][0]["excerpt"]
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
tool_response = run_detail["tool_calls"][0]["response_json"]
assert tool_response["result_type"] == "knowledge_search"
assert tool_response["record_count"] == 1
assert tool_response["hits"][0]["title"] == "差旅费报销标准"
assert tool_response["hits"][0]["document_id"] == document_id
finally:
get_settings.cache_clear()
def test_orchestrator_knowledge_session_forces_llm_wiki_search(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "住宿费报销标准是多少?",
"context_json": {
"role_codes": ["employee"],
"name": "测试用户",
"grade": "P3",
"session_type": "knowledge",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["trace_summary"]["scenario"] == "knowledge"
assert payload["result"]["citations"][0]["source_type"] == "knowledge"
assert "差旅费报销标准" in payload["result"]["answer"]
assert "核心规定是:" in payload["result"]["answer"]
assert "住宿费限额" in payload["result"]["answer"]
assert payload["result"]["suggested_actions"] == []
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
tool_response = run_detail["tool_calls"][0]["response_json"]
assert tool_response["result_type"] == "knowledge_search"
assert tool_response["record_count"] == 1
finally:
get_settings.cache_clear()
def test_orchestrator_knowledge_session_does_not_answer_from_summary_fallback(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
document_id = seed_llm_wiki_knowledge(storage_root)
document_dir = storage_root / "knowledge" / ".llm_wiki" / "documents" / document_id
(document_dir / "knowledge_candidates.json").write_text("[]", encoding="utf-8")
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "住宿费报销标准是多少?",
"context_json": {
"role_codes": ["employee"],
"name": "测试用户",
"session_type": "knowledge",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["citations"] == []
assert "知识问答仅基于 LLM Wiki 已形成的知识条目回答" in payload["result"]["answer"]
finally:
get_settings.cache_clear()
def test_orchestrator_knowledge_follow_up_reuses_recent_context(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
first_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "住宿费报销标准是多少?",
"context_json": {
"role_codes": ["employee"],
"name": "测试用户",
"session_type": "knowledge",
},
},
)
conversation_id = first_response.json()["conversation_id"]
follow_up_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"conversation_id": conversation_id,
"message": "假设p3员工去武汉出差3天一共可以报销多少钱",
"context_json": {
"role_codes": ["employee"],
"name": "测试用户",
"session_type": "knowledge",
},
},
)
assert follow_up_response.status_code == 200
payload = follow_up_response.json()
assert "差旅费报销标准" in payload["result"]["answer"]
assert "住宿费限额" in payload["result"]["answer"]
assert payload["result"]["citations"][0]["source_type"] == "knowledge"
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
tool_response = run_detail["tool_calls"][0]["response_json"]
assert tool_response["result_type"] == "knowledge_search"
assert tool_response["record_count"] == 1
finally:
get_settings.cache_clear()
def test_orchestrator_knowledge_answer_does_not_invent_missing_grade_detail(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "我去武汉出差3天一共可以报销多少钱",
"context_json": {
"role_codes": ["employee"],
"name": "曹笑竹",
"grade": "P3",
"session_type": "knowledge",
},
},
)
assert response.status_code == 200
answer = response.json()["result"]["answer"]
assert "住宿费限额" in answer
assert "350 × 3" not in answer
assert "1320 元" not in answer
finally:
get_settings.cache_clear()
def test_orchestrator_answers_direct_travel_amount_question_without_clarification(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "我要去武汉出差3天请问我一共可以报销多少费用",
"context_json": {
"role_codes": ["employee"],
"name": "曹笑竹",
"grade": "P3",
"session_type": "knowledge",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["trace_summary"]["scenario"] == "knowledge"
assert payload["status"] == "succeeded"
assert payload["result"].get("clarification_required") is not True
assert "差旅费报销标准" in payload["result"]["answer"]
assert "1320 元" not in payload["result"]["answer"]
finally:
get_settings.cache_clear()
def test_orchestrator_knowledge_follow_up_inherits_trip_conditions(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
first_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "我要去武汉出差3天请问我一共可以报销多少费用",
"context_json": {
"role_codes": ["employee"],
"name": "曹笑竹",
"grade": "P3",
"session_type": "knowledge",
},
},
)
conversation_id = first_response.json()["conversation_id"]
follow_up_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"conversation_id": conversation_id,
"message": "那P4员工可以报销多少钱",
"context_json": {
"role_codes": ["employee"],
"name": "曹笑竹",
"grade": "P3",
"session_type": "knowledge",
},
},
)
assert follow_up_response.status_code == 200
answer = follow_up_response.json()["result"]["answer"]
assert "差旅费报销标准" in answer
assert "1470 元" not in answer
finally:
get_settings.cache_clear()
def test_orchestrator_does_not_auto_seed_demo_financial_records() -> None:
client, session_factory = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "请查询我的报销单",
"context_json": {"role_codes": ["employee"], "name": "测试用户"},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["query_payload"]["record_count"] == 0
with session_factory() as db:
assert db.scalar(select(func.count()).select_from(ExpenseClaim)) == 0
assert db.scalar(select(func.count()).select_from(AccountsReceivableRecord)) == 0
assert db.scalar(select(func.count()).select_from(AccountsPayableRecord)) == 0
def test_orchestrator_scopes_my_expense_query_to_current_user() -> None:
client, session_factory = build_client()
user_id = "zhaoliu@example.com"
with session_factory() as db:
employee = Employee(
employee_no="E9001",
name="赵六",
email=user_id,
)
db.add(employee)
db.flush()
db.add_all(
[
ExpenseClaim(
claim_no="EXP-TEST-001",
employee_id=employee.id,
employee_name="赵六",
department_name="测试部",
project_code="PRJ-TEST-01",
expense_type="travel",
reason="上海客户拜访",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 10, 18, 0, tzinfo=UTC),
status="submitted",
approval_stage="finance_review",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-TEST-002",
employee_name=user_id,
department_name="测试部",
project_code="PRJ-TEST-02",
expense_type="meal",
reason="客户午餐",
location="杭州",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
status="draft",
approval_stage=None,
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-TEST-003",
employee_name="赵六",
department_name="测试部",
project_code="PRJ-TEST-03",
expense_type="hotel",
reason="历史住宿报销",
location="南京",
amount=Decimal("888.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 4, 20, 8, 30, tzinfo=UTC),
submitted_at=datetime(2026, 4, 20, 9, 30, tzinfo=UTC),
status="approved",
approval_stage="completed",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-TEST-004",
employee_name="张三",
department_name="财务部",
project_code="PRJ-OTHER-01",
expense_type="hotel",
reason="外地出差住宿",
location="深圳",
amount=Decimal("999.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 8, 30, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 9, 30, tzinfo=UTC),
status="approved",
approval_stage="completed",
risk_flags_json=[],
),
]
)
db.commit()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": user_id,
"message": "请查询我的报销单",
"context_json": {
"role_codes": ["employee"],
"name": "赵六",
"client_now_iso": "2026-05-13T08:00:00+00:00",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["selected_agent"] == "user_agent"
assert payload["status"] == "succeeded"
assert "2026-05-04 至 2026-05-13的你的报销单" in payload["result"]["answer"]
assert "共 2 笔" in payload["result"]["answer"]
assert "超过 10 日的单据" in payload["result"]["answer"]
assert payload["result"]["query_payload"]["record_count"] == 2
assert payload["result"]["query_payload"]["older_record_count"] == 1
assert payload["result"]["query_payload"]["window_start_date"] == "2026-05-04"
assert payload["result"]["query_payload"]["window_end_date"] == "2026-05-13"
assert [item["claim_no"] for item in payload["result"]["query_payload"]["records"]] == [
"EXP-TEST-002",
"EXP-TEST-001",
]
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
tool_response = run_detail["tool_calls"][0]["response_json"]
assert tool_response["record_count"] == 2
assert tool_response["total_amount"] == 420.0
assert tool_response["recent_window_applied"] is True
assert tool_response["window_start_date"] == "2026-05-04"
assert tool_response["window_end_date"] == "2026-05-13"
assert tool_response["older_record_count"] == 1
assert tool_response["scoped_to_current_user"] is True
assert tool_response["scope_label"] == "你的报销单"
assert [item["claim_no"] for item in tool_response["records"]] == [
"EXP-TEST-002",
"EXP-TEST-001",
]
def test_orchestrator_non_finance_cannot_query_other_users_expense_claims() -> None:
client, session_factory = build_client()
user_id = "manager1@example.com"
with session_factory() as db:
owner = Employee(
employee_no="E9101",
name="李经理",
email=user_id,
)
other = Employee(
employee_no="E9102",
name="王同学",
email="other@example.com",
)
db.add_all([owner, other])
db.flush()
db.add_all(
[
ExpenseClaim(
claim_no="EXP-MGR-001",
employee_id=owner.id,
employee_name="李经理",
department_name="管理部",
project_code="PRJ-MGR-01",
expense_type="travel",
reason="本人出差",
location="上海",
amount=Decimal("100.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="finance_review",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-MGR-002",
employee_id=other.id,
employee_name="王同学",
department_name="销售部",
project_code="PRJ-SALES-02",
expense_type="meal",
reason="他人报销",
location="杭州",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
status="approved",
approval_stage="completed",
risk_flags_json=[],
),
]
)
db.commit()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": user_id,
"message": "请查询王同学的报销单",
"context_json": {
"role_codes": ["manager"],
"name": "李经理",
"client_now_iso": "2026-05-13T08:00:00+00:00",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["query_payload"]["record_count"] == 1
assert [item["claim_no"] for item in payload["result"]["query_payload"]["records"]] == [
"EXP-MGR-001",
]
def test_orchestrator_non_finance_does_not_leak_duplicate_name_claims() -> None:
client, session_factory = build_client()
user_id = "zhangsan1@example.com"
with session_factory() as db:
employee_a = Employee(
employee_no="E9201",
name="张三",
email=user_id,
)
employee_b = Employee(
employee_no="E9202",
name="张三",
email="zhangsan2@example.com",
)
db.add_all([employee_a, employee_b])
db.flush()
db.add_all(
[
ExpenseClaim(
claim_no="EXP-DUP-101",
employee_id=employee_a.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
expense_type="travel",
reason="本人报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="finance_review",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-DUP-102",
employee_id=employee_b.id,
employee_name="张三",
department_name="销售部",
project_code="PRJ-B",
expense_type="meal",
reason="他人报销",
location="杭州",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
status="approved",
approval_stage="completed",
risk_flags_json=[],
),
]
)
db.commit()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": user_id,
"message": "请查询我的报销单",
"context_json": {
"role_codes": ["manager"],
"name": "张三",
"client_now_iso": "2026-05-13T08:00:00+00:00",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["query_payload"]["record_count"] == 1
assert [item["claim_no"] for item in payload["result"]["query_payload"]["records"]] == [
"EXP-DUP-101",
]
def test_orchestrator_finance_can_query_all_expense_claims() -> None:
client, session_factory = build_client()
with session_factory() as db:
db.add_all(
[
ExpenseClaim(
claim_no="EXP-FIN-001",
employee_name="",
department_name="A部",
project_code="PRJ-A",
expense_type="travel",
reason="A 报销",
location="上海",
amount=Decimal("120.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="finance_review",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-FIN-002",
employee_name="",
department_name="B部",
project_code="PRJ-B",
expense_type="meal",
reason="B 报销",
location="杭州",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
status="approved",
approval_stage="completed",
risk_flags_json=[],
),
]
)
db.commit()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "finance@example.com",
"message": "请查询所有报销单",
"context_json": {
"role_codes": ["finance"],
"name": "财务",
"client_now_iso": "2026-05-13T08:00:00+00:00",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["query_payload"]["record_count"] == 2
assert {item["claim_no"] for item in payload["result"]["query_payload"]["records"]} == {
"EXP-FIN-001",
"EXP-FIN-002",
}
def test_orchestrator_expense_query_claim_no_bypasses_recent_window() -> None:
client, session_factory = build_client()
user_id = "zhaoliu@example.com"
with session_factory() as db:
db.add(
Employee(
employee_no="E9301",
name="赵六",
email=user_id,
)
)
db.add(
ExpenseClaim(
claim_no="EXP-202604-001",
employee_name="赵六",
department_name="测试部",
project_code="PRJ-OLD-01",
expense_type="travel",
reason="上月差旅",
location="北京",
amount=Decimal("560.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 4, 1, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 4, 1, 18, 0, tzinfo=UTC),
status="approved",
approval_stage="completed",
risk_flags_json=[],
)
)
db.commit()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": user_id,
"message": "请查询报销单 EXP-202604-001",
"context_json": {
"role_codes": ["employee"],
"name": "赵六",
"client_now_iso": "2026-05-13T08:00:00+00:00",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["query_payload"]["recent_window_applied"] is False
assert payload["result"]["query_payload"]["record_count"] == 1
assert payload["result"]["query_payload"]["older_record_count"] == 0
assert payload["result"]["query_payload"]["records"][0]["claim_no"] == "EXP-202604-001"
def test_orchestrator_routes_schedule_to_hermes() -> None:
client, session_factory = build_client()
with session_factory() as db:
task = next(
item
for item in AgentAssetService(db).list_assets(asset_type="task", status="active")
if item.code == "task.hermes.daily_risk_scan"
)
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "schedule",
"task_id": task.id,
"context_json": {"role_codes": ["finance"]},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["selected_agent"] == "hermes"
assert payload["status"] == "succeeded"
assert payload["trace_summary"]["tool_count"] == 2
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
assert run_detail["agent"] == "hermes"
assert run_detail["route_json"]["selected_agent"] == "hermes"
assert len(run_detail["tool_calls"]) == 2
def test_orchestrator_forbidden_request_does_not_call_downstream_agent() -> None:
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "帮我直接付款给供应商B",
"context_json": {"role_codes": ["user"]},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["selected_agent"] is None
assert payload["permission_level"] == "forbidden"
assert payload["status"] == "blocked"
assert payload["trace_summary"]["tool_count"] == 0
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
assert run_detail["agent"] == "orchestrator"
assert run_detail["tool_calls"] == []
def test_orchestrator_approval_required_returns_confirmation_result() -> None:
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "帮我安排付款给供应商B",
"context_json": {"role_codes": ["finance"]},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["selected_agent"] == "user_agent"
assert payload["permission_level"] == "approval_required"
assert payload["requires_confirmation"] is True
assert payload["status"] == "blocked"
assert "确认" in payload["result"]["message"]
def test_orchestrator_user_agent_draft_returns_structured_payload() -> None:
client, session_factory = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "帮我生成张三4月差旅报销草稿",
"context_json": {"role_codes": ["finance"]},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["selected_agent"] == "user_agent"
assert payload["status"] == "succeeded"
assert payload["result"]["draft_payload"]["confirmation_required"] is True
assert payload["result"]["review_payload"]["slot_cards"]
assert payload["result"]["draft_payload"]["claim_id"]
assert payload["result"]["draft_payload"]["claim_no"].startswith("EXP-")
assert payload["result"]["draft_payload"]["status"] == "draft"
assert payload["result"]["suggested_actions"]
with session_factory() as db:
claim = db.scalar(
select(ExpenseClaim).where(
ExpenseClaim.id == payload["result"]["draft_payload"]["claim_id"]
)
)
assert claim is not None
assert claim.claim_no == payload["result"]["draft_payload"]["claim_no"]
assert claim.status == "draft"
assert claim.items
def test_orchestrator_expense_next_step_submits_claim_to_approval() -> None:
client, session_factory = build_client()
user_id = "zhangsan@example.com"
with session_factory() as db:
manager = Employee(
employee_no="E3000",
name="李经理",
email="manager@example.com",
)
employee = Employee(
employee_no="E3001",
name="张三",
email=user_id,
manager=manager,
)
db.add_all([manager, employee])
db.commit()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": user_id,
"message": "帮我报销昨天去上海出差的交通费680元",
"context_json": {
"role_codes": ["employee"],
"name": "张三",
"department_name": "销售部",
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"review_action": "next_step",
"review_form_values": {
"reporter_name": "张三",
"expense_type": "交通费",
"amount": "680",
"occurred_date": "2026-05-13",
"location": "上海",
"reason": "上海客户拜访交通"
},
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result"]["draft_payload"]["claim_no"].startswith("EXP-")
assert payload["result"]["draft_payload"]["status"] == "submitted"
assert payload["result"]["draft_payload"]["approval_stage"] == "直属领导审批"
assert "已完成 AI验审" in payload["result"]["answer"]
with session_factory() as db:
claim = db.scalar(
select(ExpenseClaim).where(
ExpenseClaim.id == payload["result"]["draft_payload"]["claim_id"]
)
)
assert claim is not None
assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批"
assert claim.submitted_at is not None
def test_orchestrator_blocks_fourth_expense_draft_for_same_user() -> None:
client, session_factory = build_client()
user_id = "zhangsan@example.com"
with session_factory() as db:
db.add(
Employee(
employee_no="E1001",
name="张三",
email=user_id,
)
)
db.commit()
for amount, city in ((120, "上海"), (240, "北京"), (360, "深圳")):
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": user_id,
"message": f"帮我生成报销草稿,我昨天去{city}出差,交通费{amount}",
"context_json": {
"role_codes": ["finance"],
"name": "张三",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["draft_payload"]["claim_no"].startswith("EXP-")
blocked_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": user_id,
"message": "帮我生成报销草稿我昨天去杭州出差交通费480元",
"context_json": {
"role_codes": ["finance"],
"name": "张三",
"review_action": "save_draft",
},
},
)
assert blocked_response.status_code == 200
blocked_payload = blocked_response.json()
assert blocked_payload["status"] == "succeeded"
assert "你当前已保存 3 个草稿" in blocked_payload["result"]["answer"]
assert blocked_payload["result"]["draft_payload"]["claim_id"] is None
assert blocked_payload["result"]["draft_payload"]["claim_no"] is None
assert blocked_payload["result"]["draft_payload"]["status"] == "blocked"
with session_factory() as db:
draft_count = db.scalar(
select(func.count())
.select_from(ExpenseClaim)
.where(ExpenseClaim.status == "draft")
)
assert draft_count == 3
def test_orchestrator_allows_existing_draft_update_when_user_already_has_three_drafts() -> None:
client, session_factory = build_client()
user_id = "lisi@example.com"
with session_factory() as db:
db.add(
Employee(
employee_no="E1002",
name="李四",
email=user_id,
)
)
db.commit()
first_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": user_id,
"message": "帮我生成报销草稿我昨天去上海出差交通费120元",
"context_json": {
"role_codes": ["finance"],
"name": "李四",
},
},
)
assert first_response.status_code == 200
first_payload = first_response.json()
claim_id = first_payload["result"]["draft_payload"]["claim_id"]
conversation_id = first_payload["conversation_id"]
assert claim_id
assert conversation_id
for amount, city in ((240, "北京"), (360, "深圳")):
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": user_id,
"message": f"帮我生成报销草稿,我昨天去{city}出差,交通费{amount}",
"context_json": {
"role_codes": ["finance"],
"name": "李四",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["draft_payload"]["claim_no"].startswith("EXP-")
update_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": user_id,
"conversation_id": conversation_id,
"message": "金额改成888元",
"context_json": {
"role_codes": ["finance"],
"name": "李四",
},
},
)
assert update_response.status_code == 200
update_payload = update_response.json()
assert update_payload["result"]["draft_payload"]["claim_id"] == claim_id
with session_factory() as db:
claim = db.scalar(select(ExpenseClaim).where(ExpenseClaim.id == claim_id))
assert claim is not None
assert float(claim.amount) == 888.0
draft_count = db.scalar(
select(func.count())
.select_from(ExpenseClaim)
.where(ExpenseClaim.employee_id == claim.employee_id)
.where(ExpenseClaim.status == "draft")
)
assert draft_count == 3
def test_orchestrator_persists_conversation_and_reuses_expense_draft_context() -> None:
client, session_factory = build_client()
first_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "帮我生成一份差旅报销草稿我昨天去上海出差交通费680元",
"context_json": {
"role_codes": ["finance"],
"attachment_names": ["行程单.png"],
"attachment_count": 1,
"ocr_summary": "行程单金额680元",
},
},
)
assert first_response.status_code == 200
first_payload = first_response.json()
conversation_id = first_payload["conversation_id"]
claim_id = first_payload["result"]["draft_payload"]["claim_id"]
assert conversation_id
assert claim_id
second_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"conversation_id": conversation_id,
"message": "金额改成800元",
"context_json": {
"role_codes": ["finance"],
},
},
)
assert second_response.status_code == 200
second_payload = second_response.json()
assert second_payload["conversation_id"] == conversation_id
assert second_payload["trace_summary"]["scenario"] == "expense"
assert second_payload["trace_summary"]["intent"] == "draft"
assert second_payload["result"]["draft_payload"]["claim_id"] == claim_id
with session_factory() as db:
claim = db.scalar(select(ExpenseClaim).where(ExpenseClaim.id == claim_id))
assert claim is not None
assert float(claim.amount) == 800.0
conversation = db.scalar(
select(AgentConversation).where(AgentConversation.conversation_id == conversation_id)
)
assert conversation is not None
assert conversation.draft_claim_id == claim_id
assert conversation.last_scenario == "expense"
assert conversation.last_intent == "draft"
message_count = db.scalar(
select(func.count())
.select_from(AgentConversationMessage)
.where(AgentConversationMessage.conversation_id == conversation_id)
)
assert message_count == 4
def test_orchestrator_does_not_reuse_conversation_when_user_changes() -> None:
client, session_factory = build_client()
first_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "user_a",
"message": "帮我生成一份差旅报销草稿我昨天去上海出差交通费680元",
"context_json": {"role_codes": ["finance"]},
},
)
assert first_response.status_code == 200
first_payload = first_response.json()
first_conversation_id = first_payload["conversation_id"]
assert first_conversation_id
second_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "user_b",
"conversation_id": first_conversation_id,
"message": "查一下本周报销金额",
"context_json": {"role_codes": ["finance"]},
},
)
assert second_response.status_code == 200
second_payload = second_response.json()
assert second_payload["conversation_id"]
assert second_payload["conversation_id"] != first_conversation_id
with session_factory() as db:
first_conversation = db.scalar(
select(AgentConversation).where(
AgentConversation.conversation_id == first_conversation_id
)
)
second_conversation = db.scalar(
select(AgentConversation).where(
AgentConversation.conversation_id == second_payload["conversation_id"]
)
)
assert first_conversation is not None
assert second_conversation is not None
assert first_conversation.user_id == "user_a"
assert second_conversation.user_id == "user_b"
def test_orchestrator_prunes_conversations_older_than_configured_retention_days() -> None:
client, session_factory = build_client()
expired_conversation_id = "conv_expired"
expired_at = datetime.now(UTC) - timedelta(days=2)
with session_factory() as db:
settings_service = SettingsService(db)
settings_payload = settings_service.get_settings_snapshot().model_dump()
settings_payload["sessionForm"]["conversationRetentionDays"] = 1
settings_service.save_settings_snapshot(SettingsWrite(**settings_payload))
conversation = AgentConversation(
conversation_id=expired_conversation_id,
user_id="expired_user",
source="user_message",
state_json={},
message_count=1,
created_at=expired_at,
updated_at=expired_at,
)
db.add(conversation)
db.flush()
db.add(
AgentConversationMessage(
conversation_id=expired_conversation_id,
role="user",
content="旧会话消息",
created_at=expired_at,
)
)
db.commit()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "fresh_user",
"message": "查一下本周报销金额",
"context_json": {"role_codes": ["finance"]},
},
)
assert response.status_code == 200
with session_factory() as db:
conversation = db.scalar(
select(AgentConversation).where(
AgentConversation.conversation_id == expired_conversation_id
)
)
message_count = db.scalar(
select(func.count())
.select_from(AgentConversationMessage)
.where(AgentConversationMessage.conversation_id == expired_conversation_id)
)
assert conversation is None
assert message_count == 0
def test_orchestrator_treats_expense_narrative_as_draft_instead_of_ar_query() -> None:
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "我今天去客户现场招待了客户花销了1000元",
"context_json": {"role_codes": ["finance"]},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["selected_agent"] == "user_agent"
assert payload["permission_level"] == "draft_write"
assert payload["status"] == "blocked"
assert payload["route_reason"] == "clarification_required"
assert payload["trace_summary"]["scenario"] == "expense"
assert payload["trace_summary"]["intent"] == "draft"
assert payload["trace_summary"]["tool_count"] == 0
assert "应收场景数据" not in payload["result"]["message"]
assert payload["result"]["message"].startswith("识别到您希望报销一笔“业务招待费”费用")
review_payload = payload["result"]["review_payload"]
assert review_payload["intent_summary"].startswith("识别到您希望报销一笔“业务招待费”费用。")
assert review_payload["missing_slots"] == ["客户名称", "参与人员", "票据附件"]
slot_map = {item["key"]: item for item in review_payload["slot_cards"]}
assert slot_map["time_range"]["raw_value"] == "今天"
assert slot_map["location"]["value"] == "客户现场"
assert slot_map["amount"]["value"] == "1000.00元"
def test_orchestrator_can_restore_latest_user_conversation() -> None:
client, _ = build_client()
first_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "restore_user",
"message": "帮我生成一份差旅报销草稿我昨天去上海出差交通费680元",
"context_json": {
"role_codes": ["finance"],
"attachment_names": ["行程单.png"],
"attachment_count": 1,
"ocr_summary": "行程单金额680元",
},
},
)
assert first_response.status_code == 200
first_payload = first_response.json()
second_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "restore_user",
"conversation_id": first_payload["conversation_id"],
"message": "金额改成800元",
"context_json": {"role_codes": ["finance"]},
},
)
assert second_response.status_code == 200
restore_response = client.get(
"/api/v1/orchestrator/conversations/latest",
params={"user_id": "restore_user"},
)
assert restore_response.status_code == 200
restore_payload = restore_response.json()
assert restore_payload["found"] is True
assert restore_payload["conversation"]["conversation_id"] == first_payload["conversation_id"]
assert restore_payload["conversation"]["draft_claim_id"] == first_payload["result"]["draft_payload"]["claim_id"]
assert len(restore_payload["conversation"]["messages"]) == 4
assert restore_payload["conversation"]["messages"][0]["role"] == "user"
assert restore_payload["conversation"]["messages"][0]["message_json"]["attachment_names"] == ["行程单.png"]
assert restore_payload["conversation"]["messages"][1]["message_json"]["orchestrator_payload"]["run_id"]
def test_orchestrator_restores_conversation_messages_in_sequence_order() -> None:
client, session_factory = build_client()
conversation_id = "conv_restore_sequence"
created_at = datetime(2026, 5, 13, 13, 20, tzinfo=UTC)
with session_factory() as db:
conversation = AgentConversation(
conversation_id=conversation_id,
user_id="sequence_user",
source="user_message",
state_json={"session_type": "expense"},
message_count=4,
created_at=created_at,
updated_at=created_at,
)
db.add(conversation)
db.flush()
db.add_all(
[
AgentConversationMessage(
id="msg-z-assistant",
conversation_id=conversation_id,
run_id="run-a",
role="assistant",
content="第二条:助手回复",
message_json={"sequence": 2},
created_at=created_at,
),
AgentConversationMessage(
id="msg-b-user",
conversation_id=conversation_id,
run_id="run-b",
role="user",
content="第三条:用户追问",
message_json={"sequence": 3},
created_at=created_at,
),
AgentConversationMessage(
id="msg-a-user",
conversation_id=conversation_id,
run_id="run-a",
role="user",
content="第一条:用户发起",
message_json={"sequence": 1},
created_at=created_at,
),
AgentConversationMessage(
id="msg-c-assistant",
conversation_id=conversation_id,
run_id="run-b",
role="assistant",
content="第四条:助手总结",
message_json={"sequence": 4},
created_at=created_at,
),
]
)
db.commit()
restore_response = client.get(
"/api/v1/orchestrator/conversations/latest",
params={"user_id": "sequence_user", "session_type": "expense"},
)
assert restore_response.status_code == 200
restore_payload = restore_response.json()
assert restore_payload["found"] is True
assert [item["content"] for item in restore_payload["conversation"]["messages"]] == [
"第一条:用户发起",
"第二条:助手回复",
"第三条:用户追问",
"第四条:助手总结",
]
def test_orchestrator_can_delete_all_user_conversations() -> None:
client, session_factory = build_client()
for message in ("查一下本周报销金额", "帮我生成差旅报销草稿"):
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "delete_user",
"message": message,
"context_json": {"role_codes": ["finance"]},
},
)
assert response.status_code == 200
other_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "other_user",
"message": "查一下供应商待付款",
"context_json": {"role_codes": ["finance"]},
},
)
assert other_response.status_code == 200
delete_response = client.delete(
"/api/v1/orchestrator/conversations",
params={"user_id": "delete_user"},
)
assert delete_response.status_code == 200
delete_payload = delete_response.json()
assert delete_payload["deleted_count"] == 2
with session_factory() as db:
deleted_count = db.scalar(
select(func.count())
.select_from(AgentConversation)
.where(AgentConversation.user_id == "delete_user")
)
other_count = db.scalar(
select(func.count())
.select_from(AgentConversation)
.where(AgentConversation.user_id == "other_user")
)
assert deleted_count == 0
assert other_count == 1
def test_orchestrator_can_delete_current_user_single_conversation() -> None:
client, session_factory = build_client()
first_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "single_delete_user",
"message": "查一下本周报销金额",
"context_json": {"role_codes": ["finance"]},
},
)
second_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "single_delete_user",
"message": "帮我生成差旅报销草稿",
"context_json": {"role_codes": ["finance"]},
},
)
assert first_response.status_code == 200
assert second_response.status_code == 200
first_conversation_id = first_response.json()["conversation_id"]
second_conversation_id = second_response.json()["conversation_id"]
delete_response = client.delete(
f"/api/v1/orchestrator/conversations/{first_conversation_id}",
params={"user_id": "single_delete_user"},
)
assert delete_response.status_code == 200
assert delete_response.json()["deleted_count"] == 1
with session_factory() as db:
remaining_ids = list(
db.scalars(
select(AgentConversation.conversation_id)
.where(AgentConversation.user_id == "single_delete_user")
.order_by(AgentConversation.created_at.asc())
).all()
)
assert first_conversation_id not in remaining_ids
assert second_conversation_id in remaining_ids
def test_orchestrator_can_delete_user_conversations_by_session_type() -> None:
client, session_factory = build_client()
expense_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "typed_delete_user",
"message": "帮我生成差旅报销草稿",
"context_json": {
"role_codes": ["finance"],
"session_type": "expense",
},
},
)
knowledge_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "typed_delete_user",
"message": "发票抬头不一致还能报销吗",
"context_json": {
"role_codes": ["finance"],
"session_type": "knowledge",
},
},
)
assert expense_response.status_code == 200
assert knowledge_response.status_code == 200
delete_response = client.delete(
"/api/v1/orchestrator/conversations",
params={
"user_id": "typed_delete_user",
"session_type": "knowledge",
},
)
assert delete_response.status_code == 200
assert delete_response.json()["deleted_count"] == 1
with session_factory() as db:
remaining = list(
db.scalars(
select(AgentConversation)
.where(AgentConversation.user_id == "typed_delete_user")
.order_by(AgentConversation.created_at.asc())
).all()
)
assert len(remaining) == 1
remaining_session_type = str((remaining[0].state_json or {}).get("session_type") or "").strip() or "expense"
assert remaining_session_type == "expense"
def test_orchestrator_tool_failure_is_logged_and_degraded() -> None:
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "查一下本周报销金额",
"context_json": {
"role_codes": ["finance"],
"simulate_tool_failure": "database",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["selected_agent"] == "user_agent"
assert payload["status"] == "succeeded"
assert payload["trace_summary"]["failed_tool_count"] == 1
assert payload["trace_summary"]["degraded"] is True
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
assert run_detail["tool_calls"][0]["status"] == "failed"
assert "simulated database failure" in run_detail["tool_calls"][0]["error_message"]
def test_orchestrator_exception_is_written_to_agent_run() -> None:
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "查一下本周报销金额",
"context_json": {
"role_codes": ["finance"],
"simulate_orchestrator_exception": True,
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "failed"
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
assert run_detail["status"] == "failed"
assert "simulated orchestrator exception" in run_detail["error_message"]