## 新增功能 - 文件管理模块:分类树 + 文件上传/下载/删除 - 文件上传支持拖拽(Dragger)+ 手动上传(customRequest 模式) ## 页面布局统一(参照评测执行页) - 仪表盘/评测对象/评测场景/评测报告 全部改为全高 flex 布局 - 统一内联页头样式(h2 + 竖线分隔 + 描述) - 表格撑满高度、overflow 处理 - 每页添加刷新按钮 ## Bug 修复 - 分类树操作按钮 hover 不可见(CSS 规则缺失) - 文件上传失败(multipart boundary 缺失) - LLM API 响应 content blocks 数组格式支持(_extract_content_from_api_response) - response_time_max_ms 被静默忽略(隐式规则传空 params) - 空 messages 导致 IndexError 崩溃 - poll_reply 异常中止整个 run(缺 try/catch) - engine finally 未关闭 session - 3 个页面 UTC 时间戳解析偏差 8 小时 ## 后端 - EvalEngine: poll_reply 异常保护、空 dialog 保护、session 关闭 - LLM API 响应解析支持 content-block-array 格式 - 隐式 response_time 规则正确传递 max_ms 参数 ## 前端 - api.ts: 移除手动 Content-Type(让浏览器自动添加 boundary) - Files.tsx: customRequest 替代 beforeUpload、布局优化 - index.css: 分类树 hover 规则 - Targets/Scenarios/Home/Reports: 全高布局改造 - 3 个页面时间戳改用 formatDateTime()(修复 UTC 偏差) Co-Authored-By: Claude <noreply@anthropic.com>
307 lines
10 KiB
Python
307 lines
10 KiB
Python
"""Unit tests for the async EvalEngine.
|
|
|
|
Focus on the new async behavior: cooperative cancellation, configurable
|
|
timeouts, and concurrent case execution via the semaphore.
|
|
"""
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from agenteval.channels.base import SendResult
|
|
from agenteval.evaluation.engine import CancelledError, EvalEngine, TimeoutConfig
|
|
from agenteval.models import (
|
|
Case, CaseType, ChannelType, EvalTarget, Expectation, PlatformType,
|
|
RunStatus, Scenario, TargetStatus,
|
|
)
|
|
from agenteval.storage.repository import RunRepository
|
|
from tests.unit.mock_channel import MockChannel
|
|
|
|
|
|
def _make_target() -> EvalTarget:
|
|
return EvalTarget(
|
|
id="t-1",
|
|
name="mock-target",
|
|
platform=PlatformType.AI_DIGITAL_EMPLOYEE,
|
|
channel_type=ChannelType.TUTU_API,
|
|
channel_config={
|
|
"base_url": "http://mock",
|
|
"token": "x",
|
|
"tenant": "t",
|
|
"chat_channel_id": "c",
|
|
"chat_contact_id": "u",
|
|
},
|
|
status=TargetStatus.ACTIVE,
|
|
)
|
|
|
|
|
|
def _build_engine(
|
|
scenario: Scenario,
|
|
channel: MockChannel,
|
|
*,
|
|
cancel_token: asyncio.Event | None = None,
|
|
timeout_config: TimeoutConfig | None = None,
|
|
max_concurrent_cases: int = 1,
|
|
session=None,
|
|
) -> EvalEngine:
|
|
"""Construct an EvalEngine with a MockChannel injected.
|
|
|
|
The factory still runs (needs a valid channel_config on the target) but
|
|
we immediately replace the channel with the mock.
|
|
"""
|
|
engine = EvalEngine(
|
|
target=_make_target(),
|
|
scenario=scenario,
|
|
session=session,
|
|
cancel_token=cancel_token or asyncio.Event(),
|
|
timeout_config=timeout_config,
|
|
max_concurrent_cases=max_concurrent_cases,
|
|
)
|
|
engine.channel = channel
|
|
return engine
|
|
|
|
|
|
# ── basic happy path ─────────────────────────────────────────────────────
|
|
|
|
async def test_run_single_case_completes(db_session):
|
|
scenario = Scenario(
|
|
id="s-1", name="single",
|
|
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hello"])],
|
|
)
|
|
channel = MockChannel()
|
|
engine = _build_engine(scenario, channel, session=db_session)
|
|
|
|
run = await engine.run()
|
|
|
|
assert run.status == RunStatus.COMPLETED
|
|
assert run.summary["total_cases"] == 1
|
|
assert run.summary["passed_cases"] == 1
|
|
assert channel.send_calls == 1
|
|
assert channel.poll_calls == 1
|
|
|
|
|
|
async def test_run_multi_turn_collects_all_turns(db_session):
|
|
scenario = Scenario(
|
|
id="s-1", name="multi",
|
|
cases=[Case(id="c1", type=CaseType.MULTI_TURN, messages=["a", "b", "c"])],
|
|
)
|
|
channel = MockChannel()
|
|
engine = _build_engine(scenario, channel, session=db_session)
|
|
|
|
run = await engine.run()
|
|
|
|
assert run.status == RunStatus.COMPLETED
|
|
assert channel.sent == ["a", "b", "c"]
|
|
assert channel.send_calls == 3
|
|
# Each turn polls once.
|
|
assert channel.poll_calls == 3
|
|
|
|
|
|
async def test_run_multiple_cases(db_session):
|
|
scenario = Scenario(
|
|
id="s-1", name="multi-case",
|
|
cases=[
|
|
Case(id="c1", type=CaseType.SINGLE, messages=["one"]),
|
|
Case(id="c2", type=CaseType.SINGLE, messages=["two"]),
|
|
Case(id="c3", type=CaseType.SINGLE, messages=["three"]),
|
|
],
|
|
)
|
|
channel = MockChannel()
|
|
engine = _build_engine(scenario, channel, session=db_session)
|
|
|
|
run = await engine.run()
|
|
|
|
assert run.status == RunStatus.COMPLETED
|
|
assert run.summary["total_cases"] == 3
|
|
assert channel.sent == ["one", "two", "three"]
|
|
|
|
|
|
# ── progress callback ────────────────────────────────────────────────────
|
|
|
|
async def test_progress_callback_receives_events(db_session):
|
|
scenario = Scenario(
|
|
id="s-1", name="single",
|
|
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
|
|
)
|
|
events: list[tuple[str, dict]] = []
|
|
|
|
async def cb(event: str, data: dict[str, Any]) -> None:
|
|
events.append((event, data))
|
|
|
|
channel = MockChannel()
|
|
engine = _build_engine(scenario, channel, session=db_session)
|
|
await engine.run(progress_callback=cb)
|
|
|
|
event_names = [e[0] for e in events]
|
|
assert "case_start" in event_names
|
|
assert "turn_start" in event_names
|
|
assert "turn_end" in event_names
|
|
assert "case_end" in event_names
|
|
assert "run_completed" in event_names
|
|
|
|
|
|
async def test_sync_progress_callback_also_works(db_session):
|
|
"""Engine must accept sync callbacks (CLI uses them)."""
|
|
scenario = Scenario(
|
|
id="s-1", name="single",
|
|
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
|
|
)
|
|
seen: list[str] = []
|
|
|
|
def sync_cb(event: str, data: dict) -> None:
|
|
seen.append(event)
|
|
|
|
channel = MockChannel()
|
|
engine = _build_engine(scenario, channel, session=db_session)
|
|
await engine.run(progress_callback=sync_cb)
|
|
|
|
assert "run_completed" in seen
|
|
|
|
|
|
# ── cancellation ─────────────────────────────────────────────────────────
|
|
|
|
async def test_cancel_before_run_marks_failed(db_session):
|
|
scenario = Scenario(
|
|
id="s-1", name="single",
|
|
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
|
|
)
|
|
cancel_token = asyncio.Event()
|
|
cancel_token.set() # already cancelled
|
|
|
|
channel = MockChannel()
|
|
engine = _build_engine(scenario, channel, cancel_token=cancel_token, session=db_session)
|
|
run = await engine.run()
|
|
|
|
assert run.status == RunStatus.FAILED
|
|
assert run.summary["error"]["code"] == "cancelled_by_user"
|
|
# Engine must NOT have called the channel.
|
|
assert channel.send_calls == 0
|
|
|
|
|
|
async def test_cancel_mid_run_stops_after_current_case(db_session):
|
|
"""Cancelling between cases should stop further cases from running."""
|
|
scenario = Scenario(
|
|
id="s-1", name="multi",
|
|
cases=[
|
|
Case(id="c1", type=CaseType.SINGLE, messages=["a"]),
|
|
Case(id="c2", type=CaseType.SINGLE, messages=["b"]),
|
|
Case(id="c3", type=CaseType.SINGLE, messages=["c"]),
|
|
],
|
|
)
|
|
cancel_token = asyncio.Event()
|
|
|
|
# Slow channel so we have time to set the cancel token from another task.
|
|
channel = MockChannel(reply_delay=0.05)
|
|
|
|
async def cancel_soon() -> None:
|
|
await asyncio.sleep(0.08)
|
|
cancel_token.set()
|
|
|
|
engine = _build_engine(scenario, channel, cancel_token=cancel_token, session=db_session)
|
|
|
|
run, _ = await asyncio.gather(
|
|
engine.run(),
|
|
cancel_soon(),
|
|
)
|
|
|
|
assert run.status == RunStatus.FAILED
|
|
assert run.summary["error"]["code"] == "cancelled_by_user"
|
|
# At least one case ran, but not all three.
|
|
assert 1 <= channel.send_calls < 3
|
|
|
|
|
|
# ── timeouts ─────────────────────────────────────────────────────────────
|
|
|
|
async def test_poll_timeout_records_missing_reply(db_session):
|
|
"""When the channel returns no reply within the timeout, the turn is saved
|
|
with reply=None but the engine keeps going (doesn't crash)."""
|
|
scenario = Scenario(
|
|
id="s-1", name="single",
|
|
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
|
|
)
|
|
channel = MockChannel(missing_reply=True)
|
|
engine = _build_engine(
|
|
scenario, channel,
|
|
timeout_config=TimeoutConfig(poll_reply=0.1),
|
|
session=db_session,
|
|
)
|
|
|
|
run = await engine.run()
|
|
|
|
# Engine completes (doesn't hang), turn has no reply.
|
|
assert run.status == RunStatus.COMPLETED
|
|
turns = RunRepository(db_session).get_turns(run.id)
|
|
assert len(turns) == 1
|
|
assert turns[0].reply is None
|
|
|
|
|
|
# ── concurrency ──────────────────────────────────────────────────────────
|
|
|
|
async def test_concurrent_cases_respect_semaphore(db_session):
|
|
"""With max_concurrent_cases=2, at most 2 cases should be in-flight."""
|
|
scenario = Scenario(
|
|
id="s-1", name="concurrent",
|
|
cases=[
|
|
Case(id=f"c{i}", type=CaseType.SINGLE, messages=[f"m{i}"])
|
|
for i in range(5)
|
|
],
|
|
)
|
|
in_flight = {"n": 0, "max": 0}
|
|
lock = asyncio.Lock()
|
|
|
|
class TrackedChannel(MockChannel):
|
|
async def send(self, content: str, **kwargs):
|
|
async with lock:
|
|
in_flight["n"] += 1
|
|
in_flight["max"] = max(in_flight["max"], in_flight["n"])
|
|
await asyncio.sleep(0.05)
|
|
result = await super().send(content, **kwargs)
|
|
async with lock:
|
|
in_flight["n"] -= 1
|
|
return result
|
|
|
|
channel = TrackedChannel()
|
|
engine = _build_engine(
|
|
scenario, channel,
|
|
max_concurrent_cases=2,
|
|
session=db_session,
|
|
)
|
|
|
|
run = await engine.run()
|
|
|
|
assert run.status == RunStatus.COMPLETED
|
|
assert channel.send_calls == 5
|
|
assert in_flight["max"] <= 2
|
|
|
|
|
|
# ── send failure ─────────────────────────────────────────────────────────
|
|
|
|
async def test_send_failure_aborts_case(db_session):
|
|
"""When channel.send fails, the case is marked failed but the engine
|
|
continues with the next case."""
|
|
scenario = Scenario(
|
|
id="s-1", name="mixed",
|
|
cases=[
|
|
Case(id="c1", type=CaseType.SINGLE, messages=["a"]),
|
|
Case(id="c2", type=CaseType.SINGLE, messages=["b"]),
|
|
],
|
|
)
|
|
call_count = {"n": 0}
|
|
|
|
class FlakeyChannel(MockChannel):
|
|
async def send(self, content, **kwargs):
|
|
call_count["n"] += 1
|
|
if call_count["n"] == 1:
|
|
return SendResult(ok=False, error="boom")
|
|
return await super().send(content, **kwargs)
|
|
|
|
channel = FlakeyChannel()
|
|
engine = _build_engine(scenario, channel, session=db_session)
|
|
|
|
run = await engine.run()
|
|
|
|
assert run.status == RunStatus.COMPLETED
|
|
assert run.summary["failed_cases"] == 1
|
|
assert run.summary["passed_cases"] == 1
|