refactor: 重命名 agent_chat 模块为 agent

This commit is contained in:
qzl
2026-03-02 11:13:20 +08:00
parent 2ac56e5084
commit 99d540a18d
57 changed files with 11175 additions and 74 deletions
@@ -0,0 +1,40 @@
from __future__ import annotations
import pytest
from core.agent.agui_adapter import AguiAdapter
def test_to_command_maps_payload_fields() -> None:
adapter = AguiAdapter()
command = adapter.to_command(
{
"message": "hello",
"session_id": "00000000-0000-0000-0000-000000000001",
}
)
assert command["message"] == "hello"
assert command["session_id"] == "00000000-0000-0000-0000-000000000001"
def test_to_protocol_event_maps_internal_event() -> None:
adapter = AguiAdapter()
mapped = adapter.to_protocol_event(
{
"kind": "run_completed",
"session_id": "run-1",
"output": "done",
}
)
assert mapped == {"type": "run.completed", "run_id": "run-1", "output": "done"}
def test_to_protocol_event_raises_for_invalid_event() -> None:
adapter = AguiAdapter()
with pytest.raises(ValueError):
adapter.to_protocol_event({"kind": "unknown"})
@@ -0,0 +1,30 @@
from __future__ import annotations
import pytest
from core.agent.tools.asr_fun_asr import FunASRTool
def test_transcribe_uses_injected_dashscope_callable() -> None:
def fake_transcribe(*, audio_bytes: bytes, filename: str) -> dict[str, str]:
assert filename == "voice.wav"
assert audio_bytes == b"audio"
return {"text": "你好", "request_id": "req-1"}
tool = FunASRTool(transcribe_callable=fake_transcribe)
result = tool.transcribe(audio_bytes=b"audio", filename="voice.wav")
assert result["text"] == "你好"
assert result["request_id"] == "req-1"
assert result["model"] == "fun-asr-realtime-2025-11-07"
def test_transcribe_raises_runtime_error_when_provider_fails() -> None:
def fake_transcribe(*, audio_bytes: bytes, filename: str) -> dict[str, str]:
raise RuntimeError("upstream timeout")
tool = FunASRTool(transcribe_callable=fake_transcribe)
with pytest.raises(RuntimeError):
tool.transcribe(audio_bytes=b"audio", filename="voice.wav")
@@ -0,0 +1,82 @@
from __future__ import annotations
from decimal import Decimal
import pytest
from core.agent.cost_tracker import CostTracker
def test_normalize_usage_and_cost_aggregation() -> None:
tracker = CostTracker()
tracker.add_usage(
{
"prompt_tokens": 7,
"completion_tokens": 5,
"cost": "0.002500",
}
)
tracker.add_usage(
{
"input_tokens": 5,
"output_tokens": 3,
"cost": "0.003000",
"currency": "USD",
}
)
snapshot = tracker.snapshot()
assert snapshot["input_tokens"] == 12
assert snapshot["output_tokens"] == 8
assert snapshot["total_tokens"] == 20
assert snapshot["cost"] == Decimal("0.005500")
assert snapshot["currency"] == "USD"
def test_add_usage_rejects_negative_values() -> None:
tracker = CostTracker()
with pytest.raises(ValueError):
tracker.add_usage({"input_tokens": -1})
with pytest.raises(ValueError):
tracker.add_usage({"cost": "-0.010000"})
def test_snapshot_is_zero_before_any_usage() -> None:
tracker = CostTracker()
snapshot = tracker.snapshot()
assert snapshot["input_tokens"] == 0
assert snapshot["output_tokens"] == 0
assert snapshot["total_tokens"] == 0
assert snapshot["cost"] == Decimal("0")
assert snapshot["currency"] == "USD"
def test_add_usage_rejects_currency_mismatch() -> None:
tracker = CostTracker(currency="USD")
tracker.add_usage({"input_tokens": 1, "output_tokens": 1, "cost": "0.001000"})
with pytest.raises(ValueError):
tracker.add_usage(
{
"input_tokens": 1,
"output_tokens": 1,
"cost": "0.001000",
"currency": "CNY",
}
)
def test_add_usage_rejects_non_integral_token_values() -> None:
tracker = CostTracker()
with pytest.raises(ValueError):
tracker.add_usage({"input_tokens": 1.5})
with pytest.raises(ValueError):
tracker.add_usage({"output_tokens": True})
@@ -0,0 +1,61 @@
from __future__ import annotations
import pytest
from core.agent.event_bridge import map_internal_event
def test_map_run_started_event() -> None:
event = {"kind": "run_started", "session_id": "s1"}
mapped = map_internal_event(event)
assert mapped == {"type": "run.started", "run_id": "s1"}
def test_map_message_delta_event() -> None:
event = {"kind": "message_delta", "message_id": "m1", "delta": "hello"}
mapped = map_internal_event(event)
assert mapped == {"type": "message.delta", "message_id": "m1", "delta": "hello"}
def test_map_tool_events() -> None:
started = {
"kind": "tool_started",
"message_id": "m2",
"tool_name": "asr_fun_asr",
}
completed = {
"kind": "tool_completed",
"message_id": "m2",
"tool_name": "asr_fun_asr",
"result": "ok",
}
mapped_started = map_internal_event(started)
mapped_completed = map_internal_event(completed)
assert mapped_started["type"] == "tool.started"
assert mapped_started["tool_name"] == "asr_fun_asr"
assert mapped_completed["type"] == "tool.completed"
assert mapped_completed["result"] == "ok"
def test_map_run_completed_event() -> None:
event = {"kind": "run_completed", "session_id": "s1", "output": "done"}
mapped = map_internal_event(event)
assert mapped == {"type": "run.completed", "run_id": "s1", "output": "done"}
def test_map_unknown_event_raises() -> None:
with pytest.raises(ValueError):
map_internal_event({"kind": "unknown"})
def test_map_event_missing_required_field_raises_value_error() -> None:
with pytest.raises(ValueError):
map_internal_event({"kind": "message_delta", "message_id": "m1"})
@@ -0,0 +1,89 @@
from __future__ import annotations
import pytest
from core.agent.multimodal import AttachmentInput, MultimodalProcessor
from core.agent.storage_adapter import StorageAdapter
from core.agent.tools.asr_fun_asr import FunASRTool
def test_multimodal_processes_audio_and_builds_attachment_context() -> None:
storage = StorageAdapter(bucket="agent-chat-attachments")
def fake_transcribe(*, audio_bytes: bytes, filename: str) -> dict[str, str]:
assert audio_bytes == b"audio"
assert filename == "voice.wav"
return {"text": "hello world", "request_id": "req-1"}
processor = MultimodalProcessor(
storage=storage,
asr_tool=FunASRTool(transcribe_callable=fake_transcribe),
max_file_size_mb=1,
)
result = processor.process(
user_id="u1",
session_id="s1",
message_seq=4,
attachments=[
AttachmentInput(
filename="voice.wav",
mime_type="audio/wav",
content=b"audio",
)
],
)
assert len(result.attachments) == 1
metadata = result.attachments[0]
assert (
metadata["object_path"]
== "agent-chat/u1/s1/4/6ed8919ce20490a5e3ad8630a4fab69475297abd07db73918dd5f36fcfaeb11b.wav"
)
assert metadata["mime_type"] == "audio/wav"
assert result.preview_texts == ["hello world"]
def test_multimodal_rejects_unsupported_mime_type() -> None:
storage = StorageAdapter(bucket="agent-chat-attachments")
processor = MultimodalProcessor(
storage=storage, asr_tool=FunASRTool(lambda **_: {})
)
with pytest.raises(ValueError):
processor.process(
user_id="u1",
session_id="s1",
message_seq=1,
attachments=[
AttachmentInput(
filename="malware.exe",
mime_type="application/octet-stream",
content=b"bad",
)
],
)
def test_multimodal_rejects_attachment_over_max_size() -> None:
storage = StorageAdapter(bucket="agent-chat-attachments")
processor = MultimodalProcessor(
storage=storage,
asr_tool=FunASRTool(lambda **_: {}),
max_file_size_mb=1,
)
oversized = b"x" * (1024 * 1024 + 1)
with pytest.raises(ValueError):
processor.process(
user_id="u1",
session_id="s1",
message_seq=1,
attachments=[
AttachmentInput(
filename="big.wav",
mime_type="audio/wav",
content=oversized,
)
],
)
@@ -0,0 +1,104 @@
from __future__ import annotations
from core.agent.orchestrator import AgentChatOrchestrator
async def _intent_stage(
*, message: str, context: dict[str, object]
) -> dict[str, object]:
sequence = context.setdefault("sequence", [])
if isinstance(sequence, list):
sequence.append("intent")
return {
"content": f"intent:{message}",
"usage": {"input_tokens": 2, "output_tokens": 1, "cost": "0.001000"},
}
async def _execution_stage(
*, message: str, context: dict[str, object]
) -> dict[str, object]:
sequence = context.setdefault("sequence", [])
if isinstance(sequence, list):
sequence.append("execution")
return {
"content": f"execution:{message}",
"usage": {"input_tokens": 3, "output_tokens": 2, "cost": "0.002000"},
}
async def _organization_stage(
*, message: str, context: dict[str, object]
) -> dict[str, object]:
sequence = context.setdefault("sequence", [])
if isinstance(sequence, list):
sequence.append("organization")
return {
"content": "final answer",
"usage": {"input_tokens": 4, "output_tokens": 1, "cost": "0.001500"},
}
def test_orchestrator_runs_three_stages_in_order() -> None:
orchestrator = AgentChatOrchestrator(
intent_stage=_intent_stage,
execution_stage=_execution_stage,
organization_stage=_organization_stage,
)
result = orchestrator.run_sync(run_id="run-1", user_message="hello")
assert result.context["sequence"] == ["intent", "execution", "organization"]
assert result.output == "final answer"
assert result.usage["total_tokens"] == 13
assert result.events[0]["type"] == "run.started"
assert result.events[-1]["type"] == "run.completed"
async def _failing_execution_stage(
*, message: str, context: dict[str, object]
) -> dict[str, object]:
sequence = context.setdefault("sequence", [])
if isinstance(sequence, list):
sequence.append("execution")
raise RuntimeError("boom")
def test_orchestrator_stops_and_marks_failed_when_middle_stage_raises() -> None:
orchestrator = AgentChatOrchestrator(
intent_stage=_intent_stage,
execution_stage=_failing_execution_stage,
organization_stage=_organization_stage,
)
result = orchestrator.run_sync(run_id="run-2", user_message="hello")
assert result.context["sequence"] == ["intent", "execution"]
assert result.events[-1]["type"] == "run.failed"
assert result.events[-1]["run_id"] == "run-2"
assert "boom" in (result.events[-1].get("error") or "")
assert result.failed is True
assert "boom" in (result.error or "")
def test_orchestrator_emits_stage_event_payload_shape() -> None:
orchestrator = AgentChatOrchestrator(
intent_stage=_intent_stage,
execution_stage=_execution_stage,
organization_stage=_organization_stage,
)
result = orchestrator.run_sync(run_id="run-3", user_message="hello")
for event in result.events:
assert "type" in event
assert event.get("run_id") == "run-3"
stage_events = [
event for event in result.events if event["type"] == "stage.completed"
]
assert [event["stage"] for event in stage_events] == [
"intent",
"execution",
"organization",
]
@@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import datetime
from v1.agent.service import build_session_title
def test_build_session_title_truncates_first_message() -> None:
now = datetime(2026, 2, 25, 10, 30)
title = build_session_title(
"这是一个非常长的标题会被截断到二十四个可见字符用于会话摘要", now=now
)
assert len(title) == 24
def test_build_session_title_falls_back_when_message_empty() -> None:
now = datetime(2026, 2, 25, 10, 30)
title = build_session_title("\n ", now=now)
assert title == "新对话 2026-02-25 10:30"
@@ -0,0 +1,37 @@
from __future__ import annotations
from core.agent.storage_adapter import StorageAdapter
def test_build_object_path_uses_expected_pattern() -> None:
adapter = StorageAdapter(bucket="agent-chat-attachments")
path = adapter.build_object_path(
user_id="u1",
session_id="s1",
message_seq=3,
checksum_sha256="abc123",
extension="wav",
)
assert path == "agent-chat/u1/s1/3/abc123.wav"
def test_build_attachment_metadata_contains_required_fields() -> None:
adapter = StorageAdapter(bucket="agent-chat-attachments")
metadata = adapter.build_attachment_metadata(
object_path="agent-chat/u1/s1/3/abc123.wav",
mime_type="audio/wav",
size=1024,
checksum_sha256="abc123",
origin="user_upload",
preview_text="hello",
)
assert metadata["object_path"] == "agent-chat/u1/s1/3/abc123.wav"
assert metadata["mime_type"] == "audio/wav"
assert metadata["size"] == 1024
assert metadata["checksum_sha256"] == "abc123"
assert metadata["origin"] == "user_upload"
assert metadata["preview_text"] == "hello"
@@ -2,7 +2,7 @@ from __future__ import annotations
import pytest
from core.agent_chat.agui_adapter import AguiAdapter
from core.agent.agui_adapter import AguiAdapter
def test_to_command_maps_payload_fields() -> None:
@@ -2,7 +2,7 @@ from __future__ import annotations
import pytest
from core.agent_chat.tools.asr_fun_asr import FunASRTool
from core.agent.tools.asr_fun_asr import FunASRTool
def test_transcribe_uses_injected_dashscope_callable() -> None:
@@ -4,7 +4,7 @@ from decimal import Decimal
import pytest
from core.agent_chat.cost_tracker import CostTracker
from core.agent.cost_tracker import CostTracker
def test_normalize_usage_and_cost_aggregation() -> None:
@@ -2,7 +2,7 @@ from __future__ import annotations
import pytest
from core.agent_chat.event_bridge import map_internal_event
from core.agent.event_bridge import map_internal_event
def test_map_run_started_event() -> None:
@@ -2,9 +2,9 @@ from __future__ import annotations
import pytest
from core.agent_chat.multimodal import AttachmentInput, MultimodalProcessor
from core.agent_chat.storage_adapter import StorageAdapter
from core.agent_chat.tools.asr_fun_asr import FunASRTool
from core.agent.multimodal import AttachmentInput, MultimodalProcessor
from core.agent.storage_adapter import StorageAdapter
from core.agent.tools.asr_fun_asr import FunASRTool
def test_multimodal_processes_audio_and_builds_attachment_context() -> None:
@@ -1,6 +1,6 @@
from __future__ import annotations
from core.agent_chat.orchestrator import AgentChatOrchestrator
from core.agent.orchestrator import AgentChatOrchestrator
async def _intent_stage(
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from v1.agent_chat.service import build_session_title
from v1.agent.service import build_session_title
def test_build_session_title_truncates_first_message() -> None:
@@ -1,6 +1,6 @@
from __future__ import annotations
from core.agent_chat.storage_adapter import StorageAdapter
from core.agent.storage_adapter import StorageAdapter
def test_build_object_path_uses_expected_pattern() -> None:
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from core.agent_chat.crewai.template_loader import (
from core.agent.crewai.template_loader import (
load_crewai_template,
load_tools_whitelist,
validate_workflow_stages,
@@ -63,7 +63,7 @@ tools:
def test_load_crewai_template_success_when_all_files_valid(tmp_path: Path) -> None:
static_root = _prepare_static_root(tmp_path / "agent_chat")
static_root = _prepare_static_root(tmp_path / "agent")
template = load_crewai_template(static_root)
@@ -79,7 +79,7 @@ def test_load_crewai_template_success_when_all_files_valid(tmp_path: Path) -> No
def test_load_crewai_template_raises_file_not_found_when_required_file_missing(
tmp_path: Path,
) -> None:
static_root = _prepare_static_root(tmp_path / "agent_chat")
static_root = _prepare_static_root(tmp_path / "agent")
(static_root / "crewai" / "tasks.yaml").unlink()
with pytest.raises(FileNotFoundError):
@@ -89,7 +89,7 @@ def test_load_crewai_template_raises_file_not_found_when_required_file_missing(
def test_load_crewai_template_raises_value_error_when_workflow_stages_invalid(
tmp_path: Path,
) -> None:
static_root = _prepare_static_root(tmp_path / "agent_chat")
static_root = _prepare_static_root(tmp_path / "agent")
_write(
static_root / "crewai" / "workflow.yaml",
"""
@@ -105,7 +105,7 @@ stages:
def test_load_tools_whitelist_from_tools_yaml(tmp_path: Path) -> None:
static_root = _prepare_static_root(tmp_path / "agent_chat")
static_root = _prepare_static_root(tmp_path / "agent")
whitelist = load_tools_whitelist(static_root)
@@ -124,7 +124,7 @@ def test_validate_workflow_stages_rejects_extra_or_missing_stage() -> None:
def test_load_tools_whitelist_rejects_non_string_item(tmp_path: Path) -> None:
static_root = _prepare_static_root(tmp_path / "agent_chat")
static_root = _prepare_static_root(tmp_path / "agent")
_write(
static_root / "tools.yaml",
"""
@@ -19,7 +19,7 @@ def test_llm_catalog_file_exists_and_has_required_fields() -> None:
/ "core"
/ "config"
/ "static"
/ "agent_chat"
/ "agent"
/ "llm_catalog.yaml"
)
@@ -28,7 +28,7 @@ def test_llm_catalog_file_exists_and_has_required_fields() -> None:
assert len(catalog["factories"]) == 6
assert len(catalog["llms"]) == 2
assert set(catalog["factories"][0].keys()) == {"name", "request_url", "avatar"}
assert set(catalog["llms"][0].keys()) == {"model_code", "factory_id"}
assert set(catalog["llms"][0].keys()) == {"model_code", "factory_name"}
def test_load_llm_catalog_raises_on_invalid_structure(tmp_path: Path) -> None: