refactor: unify skills+cli runtime and streamline ag-ui flow

This commit is contained in:
qzl
2026-04-22 17:09:37 +08:00
parent eeed737949
commit 4d55df45ab
111 changed files with 4858 additions and 3264 deletions
@@ -63,8 +63,6 @@ def test_text_end_event_with_bare_fields() -> None:
"stage": "worker",
"status": "success",
"answer": "done",
"key_points": ["point1"],
"result_type": "execution_report",
"suggested_actions": ["action1"],
"ui_schema": {"version": "2.0"},
"inputTokens": 100,
@@ -80,8 +78,6 @@ def test_text_end_event_with_bare_fields() -> None:
assert result["messageId"] == "assistant-run-1"
assert result["status"] == "success"
assert result["answer"] == "done"
assert result["key_points"] == ["point1"]
assert result["result_type"] == "execution_report"
assert result["suggested_actions"] == ["action1"]
assert result["ui_schema"] == {"version": "2.0"}
assert "inputTokens" not in result
@@ -101,8 +97,6 @@ def test_text_message_end_agui_event_strips_internal_usage_fields() -> None:
"stage": "worker",
"status": "success",
"answer": "done",
"key_points": [],
"result_type": "execution_report",
"suggested_actions": [],
"inputTokens": 100,
"outputTokens": 50,
@@ -122,7 +116,7 @@ def test_text_message_end_agui_event_strips_internal_usage_fields() -> None:
assert "model" not in result
def test_tool_call_result_agui_event_strips_tool_ui_fields() -> None:
def test_tool_call_result_agui_event_compiles_tool_ui_hints() -> None:
event = {
"type": "TOOL_CALL_RESULT",
"threadId": "thread-1",
@@ -140,43 +134,14 @@ def test_tool_call_result_agui_event_strips_tool_ui_fields() -> None:
"status": "success",
"title": "Done",
},
"ui_schema": {"version": "2.0"},
}
result = to_agui_wire_event(event)
assert result["type"] == "TOOL_CALL_RESULT"
assert "ui_hints" not in result
assert "ui_schema" not in result
def test_text_message_end_agui_event_compiles_ui_hints_to_ui_schema() -> None:
event = {
"type": "TEXT_MESSAGE_END",
"threadId": "thread-1",
"runId": "run-1",
"messageId": "assistant-1",
"role": "assistant",
"stage": "worker",
"status": "success",
"answer": "done",
"key_points": [],
"result_type": "summary",
"suggested_actions": [],
"ui_hints": {
"intent": "message",
"status": "info",
"body": "done",
},
}
result = to_agui_wire_event(event)
assert result["type"] == "TEXT_MESSAGE_END"
assert "ui_hints" not in result
assert isinstance(result.get("ui_schema"), dict)
def test_step_started_internal_event_keeps_step_name() -> None:
internal = {
"type": "step.start",
@@ -83,15 +83,8 @@ async def test_store_persists_worker_output_with_answer_as_content(
"stage": "worker",
"status": "success",
"answer": "worker-answer",
"key_points": [],
"result_type": "summary",
"suggested_actions": [],
"error": None,
"ui_hints": {
"intent": "message",
"status": "success",
"sections": [],
},
}
)
@@ -101,7 +94,6 @@ async def test_store_persists_worker_output_with_answer_as_content(
metadata = cast(dict[str, Any], append_kwargs["metadata"])
assert sorted(metadata.keys()) == ["agent_output", "agent_type", "run_id"]
assert metadata["agent_output"]["answer"] == "worker-answer"
assert metadata["agent_output"]["ui_hints"]["intent"] == "message"
assert append_kwargs["cost"] == Decimal("0.123")
assert append_kwargs["visibility_mask"] == ((1 << 0) | (1 << 1))
assert captured["message_delta"] == 1
@@ -165,8 +157,6 @@ async def test_store_sets_history_only_visibility_for_automation_worker_output(
"runtime_mode": "automation",
"status": "success",
"answer": "automation-result",
"key_points": [],
"result_type": "summary",
"suggested_actions": [],
"error": None,
}
@@ -194,19 +184,9 @@ async def test_store_persists_router_step_output_for_cost_tracking(
"stepName": "router",
"_router_persist": {
"router_output": {
"normalized_task_input": {
"user_text": "安排明天会议",
"context_summary": "",
},
"key_entities": [],
"constraints": [],
"task_typing": {"primary": "scheduling"},
"execution_mode": "tool_assisted",
"result_typing": {"primary": "execution_report"},
"ui": {
"ui_mode": "none",
"ui_decision_reason": "单任务",
},
"objective": "安排明天会议",
"context_summary": "",
"requires_tool_evidence": True,
},
"response_metadata": {
"model": "doubao-seed-1-6-250615",
@@ -51,7 +51,7 @@ def _run_input() -> RunAgentInput:
def _runtime_config() -> RuntimeConfig:
return RuntimeConfig(
enabled_tools=[],
enabled_skills=[],
context=MessageContextConfig(),
)
@@ -7,13 +7,7 @@ from ag_ui.core import RunAgentInput
import core.agentscope.runtime.runner as runner_module
from core.agentscope.runtime.runner import AgentScopeRunner
from schemas.agent.runtime_models import (
ExecutionMode,
NormalizedTaskInput,
ResultType,
ResultTyping,
RouterAgentOutput,
TaskType,
TaskTyping,
WorkerAgentOutputLite,
)
from schemas.agent.system_agent import AgentType
@@ -46,7 +40,7 @@ def _user_context() -> UserContext:
def _runtime_config() -> RuntimeConfig:
return RuntimeConfig(
enabled_tools=[],
enabled_skills=[],
context=MessageContextConfig(),
)
@@ -54,15 +48,9 @@ def _runtime_config() -> RuntimeConfig:
def test_build_worker_input_messages_only_contains_router_contract() -> None:
runner = AgentScopeRunner()
router_output = RouterAgentOutput(
normalized_task_input=NormalizedTaskInput(
user_text="安排明天会议",
context_summary="用户询问天气",
),
key_entities=[],
constraints=[],
task_typing=TaskTyping(primary=TaskType.SCHEDULING),
execution_mode=ExecutionMode.TOOL_ASSISTED,
result_typing=ResultTyping(primary=ResultType.EXECUTION_REPORT),
objective="安排明天会议",
context_summary="用户询问天气",
requires_tool_evidence=True,
)
input_messages = runner._build_worker_input_messages(router_output=router_output)
@@ -224,15 +212,9 @@ async def test_execute_runs_router_then_worker(
async def _fake_execute_router_step(**kwargs: object) -> RouterAgentOutput:
del kwargs
return RouterAgentOutput(
normalized_task_input=NormalizedTaskInput(
user_text="安排会议",
context_summary="用户询问天气",
),
key_entities=[],
constraints=[],
task_typing=TaskTyping(primary=TaskType.SCHEDULING),
execution_mode=ExecutionMode.TOOL_ASSISTED,
result_typing=ResultTyping(primary=ResultType.EXECUTION_REPORT),
objective="安排会议",
context_summary="用户询问天气",
requires_tool_evidence=True,
)
async def _fake_execute_worker_step(**kwargs: object) -> WorkerAgentOutputLite:
@@ -254,7 +236,7 @@ async def test_execute_runs_router_then_worker(
)
assert load_calls == [AgentType.ROUTER, AgentType.WORKER]
assert result["router"]["normalized_task_input"]["user_text"] == "安排会议"
assert result["router"]["objective"] == "安排会议"
assert result["worker"]["answer"] == "ok"
@@ -289,15 +271,9 @@ async def test_execute_raises_cancelled_error_before_worker_when_cancel_requeste
async def _fake_execute_router_step(**kwargs: object) -> RouterAgentOutput:
del kwargs
return RouterAgentOutput(
normalized_task_input=NormalizedTaskInput(
user_text="安排会议",
context_summary="",
),
key_entities=[],
constraints=[],
task_typing=TaskTyping(primary=TaskType.SCHEDULING),
execution_mode=ExecutionMode.TOOL_ASSISTED,
result_typing=ResultTyping(primary=ResultType.EXECUTION_REPORT),
objective="安排会议",
context_summary="",
requires_tool_evidence=False,
)
async def _fake_execute_worker_step(**kwargs: object) -> WorkerAgentOutputLite:
@@ -98,7 +98,7 @@ async def test_run_agentscope_task_calls_runtime_run(
"owner_id": str(uuid4()),
"run_input": _run_input_payload(),
"runtime_config": {
"enabled_tools": [],
"enabled_skills": [],
"context": {"window_mode": "day", "window_count": 2},
},
}
@@ -154,7 +154,7 @@ async def test_run_agentscope_task_injects_runtime_config(
"owner_id": str(uuid4()),
"run_input": _run_input_payload(),
"runtime_config": {
"enabled_tools": [],
"enabled_skills": [],
"context": {"window_mode": "day", "window_count": 2},
},
}
@@ -218,7 +218,7 @@ async def test_run_agentscope_task_injects_cancel_checker(
"owner_id": str(uuid4()),
"run_input": _run_input_payload(),
"runtime_config": {
"enabled_tools": [],
"enabled_skills": [],
"context": {"window_mode": "day", "window_count": 2},
},
}
@@ -0,0 +1,85 @@
from __future__ import annotations
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
class TestToolAgentOutputResultCoercion:
def test_dict_result_stays_dict(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result={"total": 0, "items": []},
)
assert isinstance(output.result, dict)
assert output.result == {"total": 0, "items": []}
def test_list_result_stays_list(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result=[{"id": "evt_1"}],
)
assert isinstance(output.result, list)
def test_string_result_stays_string(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result="not json",
)
assert isinstance(output.result, str)
assert output.result == "not json"
def test_none_result_stays_none(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.FAILURE,
result=None,
error={"code": "ERR", "message": "fail", "retryable": False},
)
assert output.result is None
def test_ui_hints_preserved(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result={"total": 0, "items": []},
ui_hints={"view": "calendar_event_list", "total": 0},
)
assert output.ui_hints == {"view": "calendar_event_list", "total": 0}
def test_model_dump_includes_ui_hints(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result={"total": 0, "items": []},
ui_hints={"view": "calendar_event_list"},
)
dumped = output.model_dump(mode="json", exclude_none=True)
assert "ui_hints" in dumped
assert dumped["ui_hints"] == {"view": "calendar_event_list"}
assert isinstance(dumped["result"], dict)
def test_model_dump_excludes_none_ui_hints(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result={"total": 0},
ui_hints=None,
)
dumped = output.model_dump(mode="json", exclude_none=True)
assert "ui_hints" not in dumped
@@ -11,7 +11,7 @@ def test_build_agent_prompt_for_worker_contains_runtime_config() -> None:
{
"temperature": 0.2,
"context_messages": {"mode": "number", "count": 20},
"enabled_tools": ["calendar.read", "calendar.write"],
"enabled_skills": ["calendar", "contacts"],
}
),
)
@@ -20,10 +20,10 @@ def test_build_agent_prompt_for_worker_contains_runtime_config() -> None:
assert "- type: worker" in prompt
assert "context_messages.mode=number" in prompt
assert "context_messages.count=20" in prompt
assert "enabled_tools=calendar.read,calendar.write" in prompt
assert "enabled_skills=calendar,contacts" in prompt
def test_build_agent_prompt_for_router_contains_task_typing_rules() -> None:
def test_build_agent_prompt_for_router_contains_identity_and_config() -> None:
prompt = build_agent_prompt(
agent_type=AgentType.ROUTER,
llm_config=SystemAgentLLMConfig.model_validate(
@@ -1,400 +0,0 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any, cast
from uuid import UUID, uuid4
import pytest
from agentscope.tool import ToolResponse
from core.agentscope.tools.custom import calendar as calendar_module
def _decode_tool_response(response: ToolResponse) -> dict[str, Any]:
assert response.content
first = response.content[0]
if isinstance(first, dict):
text = str(first.get("text", ""))
else:
text = str(getattr(first, "text", ""))
return json.loads(text)
@dataclass
class _FakeService:
created_request: Any = None
created_id: str = field(default_factory=lambda: str(uuid4()))
list_calls: list[dict[str, Any]] = field(default_factory=list)
range_calls: list[dict[str, Any]] = field(default_factory=list)
deleted_ids: list[str] = field(default_factory=list)
async def list_paginated(
self, *, page: int, page_size: int, query: str | None = None
):
self.list_calls.append({"page": page, "page_size": page_size, "query": query})
item = SimpleNamespace(
id=UUID(self.created_id),
title="会议",
description="今天下午五点的会议",
start_at=datetime(2026, 3, 17, 9, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 17, 9, 30, tzinfo=timezone.utc),
timezone="Asia/Shanghai",
status="active",
metadata=SimpleNamespace(
location=None, color="#4F46E5", reminder_minutes=15
),
)
return [item], 1
async def list_by_date_range(self, request: Any):
self.range_calls.append(
{
"start_at": request.start_at,
"end_at": request.end_at,
}
)
return [
SimpleNamespace(
id=UUID(self.created_id),
owner_id=uuid4(),
title="会议",
description="今天下午五点的会议",
start_at=datetime(2026, 3, 17, 9, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 17, 9, 30, tzinfo=timezone.utc),
timezone="Asia/Shanghai",
status="active",
source_type="manual",
metadata=None,
subscribers=[],
)
]
async def create_agent_generated(self, request):
self.created_request = request
return SimpleNamespace(
id=UUID(self.created_id),
title=request.title,
description=request.description,
start_at=request.start_at,
end_at=request.end_at,
timezone=request.timezone,
metadata=request.metadata,
)
async def delete(self, item_id: UUID) -> None:
self.deleted_ids.append(str(item_id))
async def share(self, item_id: UUID, request: Any) -> None:
if not hasattr(self, "share_calls"):
self.share_calls = []
self.share_calls.append({"item_id": str(item_id), "request": request})
@pytest.mark.asyncio
async def test_calendar_write_requires_runtime_context() -> None:
result = await calendar_module.calendar_write(
operations=[calendar_module.CalendarWriteOperation(action="create")]
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "MISSING_RUNTIME_ARGS"
@pytest.mark.asyncio
async def test_calendar_write_create_requires_start_at(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_write(
operations=[
calendar_module.CalendarWriteOperation(
action="create",
event_timezone="Asia/Shanghai",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "start_at" in payload["error"]["message"]
@pytest.mark.asyncio
async def test_calendar_write_create_requires_event_timezone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_write(
operations=[
calendar_module.CalendarWriteOperation(
action="create",
start_at="2026-03-16T09:00:00+08:00",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "event_timezone" in payload["error"]["message"]
@pytest.mark.asyncio
async def test_calendar_write_rejects_naive_start_at(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_write(
operations=[
calendar_module.CalendarWriteOperation(
action="create",
start_at="2026-03-16T09:00:00",
event_timezone="Asia/Shanghai",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "时区" in payload["error"]["message"]
@pytest.mark.asyncio
async def test_calendar_write_create_normalizes_to_utc(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_write(
operations=[
calendar_module.CalendarWriteOperation(
action="create",
title="晨会",
start_at="2026-03-16T09:00:00+08:00",
end_at="2026-03-16T10:00:00+08:00",
event_timezone="Asia/Shanghai",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert payload["result"].startswith("status=success")
assert "items=[{status=success,eventId=" in payload["result"]
assert fake_service.created_id in payload["result"]
assert fake_service.created_request is not None
request = fake_service.created_request
assert request.timezone == "Asia/Shanghai"
assert request.start_at == datetime(2026, 3, 16, 1, 0, tzinfo=timezone.utc)
assert request.end_at == datetime(2026, 3, 16, 2, 0, tzinfo=timezone.utc)
@pytest.mark.asyncio
async def test_calendar_write_batch_supports_create_and_delete(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_write(
operations=[
calendar_module.CalendarWriteOperation(
action="create",
title="晨会",
start_at="2026-03-16T09:00:00+08:00",
event_timezone="Asia/Shanghai",
),
calendar_module.CalendarWriteOperation(
action="delete",
event_id=str(uuid4()),
),
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert "success=2" in payload["result"]
assert len(fake_service.deleted_ids) == 1
@pytest.mark.asyncio
async def test_calendar_read_returns_structured_result_with_ids(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_read(
start_at="2026-03-17T00:00:00+08:00",
end_at="2026-03-18T00:00:00+08:00",
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
result_data = json.loads(payload["result"])
assert payload["status"] == "success"
assert result_data["total"] == 1
assert result_data["items"][0]["id"] == fake_service.created_id
assert result_data["items"][0]["timezone"] == "Asia/Shanghai"
assert result_data["items"][0]["description"] == "今天下午五点的会议"
assert result_data["items"][0]["status"] == "active"
assert fake_service.range_calls == [
{
"start_at": datetime(2026, 3, 16, 16, 0, tzinfo=timezone.utc),
"end_at": datetime(2026, 3, 17, 16, 0, tzinfo=timezone.utc),
}
]
@pytest.mark.asyncio
async def test_calendar_read_rejects_naive_datetime_string(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_read(
start_at="2026-03-17T00:00:00",
end_at="2026-03-18T00:00:00+08:00",
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "时区" in payload["error"]["message"]
@pytest.mark.asyncio
async def test_calendar_share_executes_with_valid_invitee(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
event_id = str(uuid4())
result = await calendar_module.calendar_share(
event_id=event_id,
invitees=[
calendar_module.CalendarShareInvitee(
phone="13900001234",
permissionView=True,
permissionEdit=False,
permissionInvite=False,
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert payload["result"].startswith("status=success success=1 failed=0")
assert "+8613900001234" in payload["result"]
assert len(fake_service.share_calls) == 1
share_call = fake_service.share_calls[0]
assert share_call["item_id"] == event_id
assert share_call["request"].phone == "+8613900001234"
@pytest.mark.asyncio
async def test_calendar_share_rejects_invalid_phone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_share(
event_id=str(uuid4()),
invitees=[
calendar_module.CalendarShareInvitee(
phone="12345",
permissionView=True,
permissionEdit=False,
permissionInvite=False,
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
@pytest.mark.asyncio
async def test_calendar_share_accepts_json_invitee_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
event_id = str(uuid4())
result = await calendar_module.calendar_share(
event_id=event_id,
invitees=cast(
Any,
[
{
"phone": "8613900001234",
"permissionView": True,
"permissionEdit": False,
"permissionInvite": False,
}
],
),
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert payload["result"].startswith("status=success success=1 failed=0")
assert len(fake_service.share_calls) == 1
share_call = fake_service.share_calls[0]
assert share_call["item_id"] == event_id
assert share_call["request"].phone == "+8613900001234"
@@ -1,170 +0,0 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from uuid import uuid4
import pytest
from agentscope.tool import ToolResponse
from core.agentscope.tools.custom import memory as memory_module
from models.memories import MemoryType
from schemas.domain.memory_content import UserMemoryContent
def _decode_tool_response(response: ToolResponse) -> dict[str, object]:
assert response.content
first = response.content[0]
text = str(first.get("text", "")) if isinstance(first, dict) else str(first.text)
return json.loads(text)
def _payload_error_code(payload: dict[str, object]) -> str:
error = payload.get("error")
if not isinstance(error, dict):
return ""
return str(error.get("code") or "")
class _FakeMemoriesService:
def __init__(self) -> None:
self.memory: object | None = None
self.updated_user = 0
self.updated_work = 0
async def get_memory_model(self, *, memory_type: MemoryType):
_ = memory_type
return self.memory
async def update_user_memory(self, **kwargs):
_ = kwargs
self.updated_user += 1
return SimpleNamespace()
async def update_work_memory(self, **kwargs):
_ = kwargs
self.updated_work += 1
return SimpleNamespace()
def _user_memory():
return SimpleNamespace(
id=uuid4(),
owner_id=uuid4(),
memory_type=MemoryType.USER,
content={"preferences": {"communication_style": "简洁"}},
status="active",
)
@pytest.mark.asyncio
async def test_memory_write_requires_runtime_context() -> None:
response = await memory_module.memory_write(
operations=[
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["跑步"]),
)
],
)
payload = _decode_tool_response(response)
assert payload["status"] == "failure"
assert _payload_error_code(payload) == "MISSING_RUNTIME_ARGS"
@pytest.mark.asyncio
async def test_memory_write_updates_user_content(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeMemoriesService()
monkeypatch.setattr(
memory_module, "create_memories_service", lambda **_: fake_service
)
response = await memory_module.memory_write(
operations=[
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["阅读"]),
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(response)
assert payload["status"] == "success"
assert "success=1" in str(payload["result"])
assert "updated_types=[user]" in str(payload["result"])
assert fake_service.updated_user == 1
@pytest.mark.asyncio
async def test_memory_forget_updates_content_paths(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeMemoriesService()
fake_service.memory = _user_memory()
monkeypatch.setattr(
memory_module, "create_memories_service", lambda **_: fake_service
)
response = await memory_module.memory_forget(
operations=[
memory_module.MemoryForgetArgs(
memory_type=MemoryType.USER,
forget_paths=["preferences.communication_style"],
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(response)
assert payload["status"] == "success"
assert "success=1" in str(payload["result"])
assert "forgotten=1" in str(payload["result"])
assert fake_service.updated_user == 1
@pytest.mark.asyncio
async def test_memory_write_partial_status_contains_error_details(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeMemoriesService()
call_count = 0
async def _update_user_memory(**kwargs):
nonlocal call_count
_ = kwargs
call_count += 1
if call_count == 2:
raise ValueError("invalid payload")
fake_service.updated_user += 1
return SimpleNamespace()
fake_service.update_user_memory = _update_user_memory # type: ignore[method-assign]
monkeypatch.setattr(
memory_module, "create_memories_service", lambda **_: fake_service
)
response = await memory_module.memory_write(
operations=[
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["阅读"]),
),
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["跑步"]),
),
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(response)
assert payload["status"] == "partial"
assert "status=partial" in str(payload["result"])
assert "failed=1" in str(payload["result"])
assert _payload_error_code(payload) in {"INVALID_ARGUMENT", "UNKNOWN_ERROR"}
@@ -1,25 +0,0 @@
from __future__ import annotations
from core.agentscope.prompts.route_prompt import (
build_frontend_route_prompt,
load_frontend_routes_catalog,
)
def test_load_frontend_routes_catalog_contains_known_routes() -> None:
catalog = load_frontend_routes_catalog()
assert catalog.version == "1.0"
route_ids = {route.route_id for route in catalog.routes}
assert "home.main" in route_ids
assert "calendar.event_detail" in route_ids
assert "todo.detail" in route_ids
def test_build_frontend_route_prompt_has_guidance_and_routes() -> None:
prompt = build_frontend_route_prompt()
assert "[Frontend Route Catalog]" in prompt
assert "version=1.0" in prompt
assert "route_id=home.main; path=/home;" in prompt
assert "route_id=calendar.event_detail; path=/calendar/events/{id};" in prompt
@@ -137,22 +137,12 @@ def test_build_system_prompt_keeps_sections_focused_without_language_duplication
agent_type=AgentType.WORKER,
user_context=_build_user_context(),
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
tools=[
{
"name": "calendar.read",
"description": "读取日程",
"parameters": {"type": "object"},
}
],
)
assert "[Identity]" in prompt
assert "[Runtime Context]" in prompt
assert "<!-- ROUTE_START -->" in prompt
assert "[Safety Rules]" in prompt
assert "[Frontend Route Catalog]" in prompt
assert "[Agent Identity]" in prompt
assert "[Available Tools]" in prompt
assert "[Answer Style]" in prompt
assert "Default reply language:" not in prompt
assert "Follow agent contracts strictly" not in prompt
@@ -1,22 +0,0 @@
from __future__ import annotations
from core.agentscope.prompts.tool_prompt import build_tools_prompt
def test_build_tools_prompt_wraps_section_and_schema() -> None:
prompt = build_tools_prompt(
tools=[
{
"name": "calendar.read",
"description": "读取日程",
"parameters": {
"type": "object",
"properties": {"page": {"type": "integer"}},
},
}
]
)
assert "<!-- TOOLS_START -->" in prompt
assert "calendar.read" in prompt
assert '"page":{"type":"integer"}' in prompt
@@ -0,0 +1,105 @@
from __future__ import annotations
import json
import sys
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from core.agentscope.tools.tool_call_context import store_tool_agent_output, peek_tool_agent_output, consume_tool_agent_output
def _load_parsing_module():
module_name = "test_agentscope_parsing"
module_path = (
Path(__file__).resolve().parents[4] / "src/core/agentscope/utils/parsing.py"
)
spec = spec_from_file_location(module_name, module_path)
assert spec is not None
assert spec.loader is not None
module = module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
parsing_module = _load_parsing_module()
parse_tool_agent_output = parsing_module.parse_tool_agent_output
project_tool_result_text = parsing_module.project_tool_result_text
def test_project_tool_result_text_returns_json_projection() -> None:
result = {
"status": "success",
"items": [{"id": "evt_1"}],
}
projected = project_tool_result_text(result)
assert projected == '{"status":"success","items":[{"id":"evt_1"}]}'
def test_parse_tool_agent_output_uses_side_channel_payload() -> None:
tool_call_id = "call-1"
store_tool_agent_output(
tool_call_id=tool_call_id,
payload={
"tool_name": "calendar.write",
"tool_call_id": tool_call_id,
"tool_call_args": {"title": "Sync"},
"status": "success",
"result": {
"status": "success",
"event": {"id": "evt_1"},
},
"ui_hints": {"view": "calendar_event_created"},
},
)
output = [{"type": "text", "text": json.dumps({"status": "success"})}]
parsed = parse_tool_agent_output(
output,
tool_call_id=tool_call_id,
tool_name="calendar.write",
tool_call_args={"title": "Sync"},
)
assert parsed is not None
assert parsed.tool_name == "calendar.write"
assert parsed.tool_call_id == tool_call_id
assert parsed.result == {"status": "success", "event": {"id": "evt_1"}}
assert parsed.ui_hints == {"view": "calendar_event_created"}
def test_peek_does_not_consume() -> None:
tool_call_id = "peek-test-1"
store_tool_agent_output(
tool_call_id=tool_call_id,
payload={
"tool_name": "calendar.read",
"tool_call_id": tool_call_id,
"tool_call_args": {},
"status": "success",
"result": {"total": 0, "items": []},
"ui_hints": {"view": "calendar_event_list"},
},
)
peeked = peek_tool_agent_output(tool_call_id=tool_call_id)
assert peeked is not None
assert peeked["tool_name"] == "calendar.read"
assert peeked["ui_hints"] == {"view": "calendar_event_list"}
peeked_again = peek_tool_agent_output(tool_call_id=tool_call_id)
assert peeked_again is not None
consumed = consume_tool_agent_output(tool_call_id=tool_call_id)
assert consumed is not None
after_consume = peek_tool_agent_output(tool_call_id=tool_call_id)
assert after_consume is None
def test_peek_returns_none_for_missing() -> None:
result = peek_tool_agent_output(tool_call_id="nonexistent")
assert result is None
@@ -1,30 +0,0 @@
from __future__ import annotations
from typing import Any, cast
from uuid import uuid4
from core.agentscope.tools.toolkit import build_stage_toolkit
from schemas.agent.system_agent import AgentType
def test_build_stage_toolkit_uses_explicit_enabled_tools_as_final_set(
monkeypatch,
) -> None:
captured: dict[str, object] = {}
def _fake_build_toolkit(**kwargs):
captured.update(kwargs)
return object()
monkeypatch.setattr(
"core.agentscope.tools.toolkit.build_toolkit", _fake_build_toolkit
)
build_stage_toolkit(
agent_type=AgentType.WORKER,
session=cast(Any, object()),
owner_id=uuid4(),
enabled_tool_names={"calendar_read", "user_lookup"},
)
assert captured["enabled_tool_names"] == {"calendar_read", "user_lookup"}
@@ -1,34 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.tools.toolkit import build_toolkit
@pytest.mark.asyncio
async def test_build_toolkit_registers_calendar_tools() -> None:
pytest.importorskip("agentscope")
toolkit = build_toolkit(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
)
schemas = toolkit.get_json_schemas()
names = {item["function"]["name"] for item in schemas}
assert "calendar_read" in names
assert "calendar_write" in names
assert "calendar_share" in names
assert "memory_write" in names
assert "memory_forget" in names
write_schema = next(
item for item in schemas if item["function"]["name"] == "calendar_write"
)
params = write_schema["function"]["parameters"]["properties"]
assert "user_token" not in params
assert "session" not in params
assert "owner_id" not in params
@@ -1,63 +0,0 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
from uuid import uuid4
import pytest
from agentscope.tool import ToolResponse
from core.agentscope.tools.custom.user_lookup import user_lookup
import core.agentscope.tools.custom.user_lookup as user_lookup_module
def _decode_tool_response(response: ToolResponse) -> dict[str, Any]:
assert response.content
first = response.content[0]
if isinstance(first, dict):
text = str(first.get("text", ""))
else:
text = str(getattr(first, "text", ""))
return json.loads(text)
@pytest.mark.asyncio
async def test_user_lookup_requires_runtime_context() -> None:
result = await user_lookup()
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "MISSING_RUNTIME_ARGS"
@pytest.mark.asyncio
async def test_user_lookup_returns_friend_contacts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_list_friend_contacts(**_: Any) -> list[dict[str, str]]:
return [
{
"userId": "00000000-0000-0000-0000-000000000101",
"username": "alice",
"phone": "+8613900000001",
},
{
"userId": "00000000-0000-0000-0000-000000000102",
"username": "bob",
"phone": "+8613900000002",
},
]
monkeypatch.setattr(
user_lookup_module,
"_list_friend_contacts",
_fake_list_friend_contacts,
)
result = await user_lookup(session=SimpleNamespace(), owner_id=uuid4())
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert "friends_count=2" in payload["result"]
assert "username=alice" in payload["result"]
assert "+8613900000001" in payload["result"]
@@ -0,0 +1,62 @@
from __future__ import annotations
import pytest
from core.agentscope.tools.cli.models import CliCommand, CliCommandResult
from core.agentscope.tools.cli.router import CommandRouter
@pytest.mark.asyncio
async def test_router_register_and_dispatch() -> None:
router = CommandRouter()
async def mock_handler(request: CliCommand) -> CliCommandResult:
return CliCommandResult(ok=True, command=request.command, subcommand=request.subcommand, data={"name": request.args["name"]})
router.register(command="test", subcommand="run", handler=mock_handler)
assert ("test", "run") in router.command_pairs
result = await router.dispatch(CliCommand(command="test", subcommand="run", args={"name": "demo"}, owner_id="u1"))
assert result.ok is True
assert result.data == {"name": "demo"}
@pytest.mark.asyncio
async def test_router_unknown_command() -> None:
router = CommandRouter()
result = await router.dispatch(CliCommand(command="unknown", subcommand="run", args={}, owner_id="u1"))
assert result.ok is False
assert result.error is not None
assert result.error.code == "UNKNOWN_COMMAND"
@pytest.mark.asyncio
async def test_router_handler_exception() -> None:
router = CommandRouter()
async def failing_handler(request: CliCommand) -> CliCommandResult:
del request
raise ValueError("intentional error")
router.register(command="fail", subcommand="run", handler=failing_handler)
result = await router.dispatch(CliCommand(command="fail", subcommand="run", args={}, owner_id="u1"))
assert result.ok is False
assert result.error is not None
assert result.error.code == "HANDLER_ERROR"
def test_router_duplicate_register() -> None:
router = CommandRouter()
async def handler1(request: CliCommand) -> CliCommandResult:
return CliCommandResult(ok=True, command=request.command, subcommand=request.subcommand)
async def handler2(request: CliCommand) -> CliCommandResult:
return CliCommandResult(ok=True, command=request.command, subcommand=request.subcommand)
router.register(command="cmd", subcommand="one", handler=handler1)
with pytest.raises(ValueError, match="already registered"):
router.register(command="cmd", subcommand="one", handler=handler2)
@@ -0,0 +1,73 @@
from __future__ import annotations
from core.agentscope.tools.tool_postprocessor import postprocess_tool_output
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
def _make_tool_output(
*,
command: str,
subcommand: str,
status: ToolStatus,
data: dict | None = None,
) -> ToolAgentOutput:
return ToolAgentOutput(
tool_name="project_cli",
tool_call_id="test_call_id",
tool_call_args={"command": command, "subcommand": subcommand, "args": {}},
status=status,
result={"command": command, "subcommand": subcommand, "data": data or {}},
error=None,
ui_hints=None,
)
def test_postprocess_calendar_read_success() -> None:
output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.SUCCESS, data={"total": 5, "items": []})
processed = postprocess_tool_output(output)
assert processed.ui_hints is not None
assert processed.ui_hints["view"] == "calendar_event_list"
assert processed.ui_hints["total"] == 5
def test_postprocess_calendar_write_partial() -> None:
output = _make_tool_output(command="calendar", subcommand="write", status=ToolStatus.PARTIAL, data={"status": "partial", "results": []})
processed = postprocess_tool_output(output)
assert processed.ui_hints is not None
assert processed.ui_hints["view"] == "calendar_batch_result"
assert processed.ui_hints["status"] == "partial"
def test_postprocess_contacts_lookup_success() -> None:
output = _make_tool_output(command="contacts", subcommand="lookup", status=ToolStatus.SUCCESS, data={"friends_count": 3, "friends": []})
processed = postprocess_tool_output(output)
assert processed.ui_hints is not None
assert processed.ui_hints["view"] == "contact_list"
assert processed.ui_hints["friends_count"] == 3
def test_postprocess_memory_forget_success() -> None:
output = _make_tool_output(command="memory", subcommand="forget", status=ToolStatus.SUCCESS, data={"status": "success", "forgotten": 5})
processed = postprocess_tool_output(output)
assert processed.ui_hints is not None
assert processed.ui_hints["forgotten"] == 5
def test_postprocess_failure_no_ui_hints() -> None:
output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.FAILURE, data=None)
processed = postprocess_tool_output(output)
assert processed.ui_hints is None
def test_postprocess_unknown_command_no_ui_hints() -> None:
output = _make_tool_output(command="unknown", subcommand="run", status=ToolStatus.SUCCESS, data={"data": "test"})
processed = postprocess_tool_output(output)
assert processed.ui_hints is None
def test_postprocess_preserves_existing_ui_hints() -> None:
output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.SUCCESS, data={"total": 5})
output = output.model_copy(update={"ui_hints": {"view": "custom_view", "custom": True}})
processed = postprocess_tool_output(output)
assert processed.ui_hints["view"] == "custom_view"
assert processed.ui_hints["custom"] is True
@@ -0,0 +1,61 @@
import asyncio
from core.agentscope.tools.internal.project_cli import PROJECT_CLI_TOOL_NAME
from core.agentscope.tools.internal.view_skill_file import VIEW_SKILL_FILE_TOOL_NAME
from core.agentscope.tools.internal import make_view_skill_file_wrapper
from core.agentscope.tools.toolkit import build_toolkit
from schemas.agent.skill_config import SkillName
def test_skill_names_is_complete() -> None:
expected = {"calendar", "contacts", "memory"}
assert {skill.value for skill in SkillName} == expected
def test_validate_rejects_unknown_skill() -> None:
from core.agentscope.tools.toolkit import _validate_enabled_skill_names
try:
_validate_enabled_skill_names({"calendar", "nonexistent"})
assert False, "should have raised"
except ValueError as exc:
assert "nonexistent" in str(exc)
def test_validate_accepts_known_skills() -> None:
from core.agentscope.tools.toolkit import _validate_enabled_skill_names
result = _validate_enabled_skill_names({"calendar", "contacts"})
assert result == {"calendar", "contacts"}
def test_build_toolkit_registers_project_cli() -> None:
toolkit = build_toolkit()
schemas = toolkit.get_json_schemas()
assert {item["function"]["name"] for item in schemas} == {
PROJECT_CLI_TOOL_NAME,
VIEW_SKILL_FILE_TOOL_NAME,
}
def test_view_skill_file_rejects_path_outside_enabled_skill_dirs() -> None:
wrapper = make_view_skill_file_wrapper(enabled_skill_names={"calendar"})
response = asyncio.run(
wrapper(file_path="/tmp/not-allowed.txt", ranges=None),
)
assert response.content
block = response.content[0]
text = block["text"] if isinstance(block, dict) else block.text
assert "ACCESS_DENIED" in text or "access denied" in text.lower()
def test_view_skill_file_reads_enabled_skill_file() -> None:
wrapper = make_view_skill_file_wrapper(enabled_skill_names={"calendar"})
response = asyncio.run(wrapper(file_path="calendar/SKILL.md", ranges=[1, 10]))
assert response.content
block = response.content[0]
text = block["text"] if isinstance(block, dict) else block.text
assert "Calendar Skill" in text or "name: calendar" in text
@@ -0,0 +1,143 @@
from __future__ import annotations
import time
from uuid import uuid4
import jwt
import pytest
from core.auth.credential_issuer import ToolCredentialIssuer
from core.auth.jwt_verifier import TokenValidationError
_ISSUER = "https://example.com/auth/v1"
_SECRET = "test-secret-key-for-testing-only"
_ALGORITHM = "HS256"
_TTL = 600
def _make_issuer(**overrides) -> ToolCredentialIssuer:
kwargs = {
"jwt_secret": _SECRET,
"jwt_algorithm": _ALGORITHM,
"jwt_issuer": _ISSUER,
"ttl_seconds": _TTL,
}
kwargs.update(overrides)
return ToolCredentialIssuer(**kwargs)
class TestToolCredentialIssuerIssue:
def test_issue_returns_valid_jwt(self) -> None:
issuer = _make_issuer()
owner_id = str(uuid4())
token = issuer.issue(owner_id=owner_id, mode="chat")
assert isinstance(token, str)
payload = jwt.decode(
token,
_SECRET,
algorithms=[_ALGORITHM],
audience="agent-tool-runtime",
)
assert payload["sub"] == owner_id
assert payload["aud"] == "agent-tool-runtime"
assert payload["iss"] == _ISSUER
assert payload["purpose"] == "agent_tool_runtime"
assert payload["mode"] == "chat"
assert "exp" in payload
assert "iat" in payload
def test_issue_defaults_mode_to_chat(self) -> None:
issuer = _make_issuer()
token = issuer.issue(owner_id=str(uuid4()))
payload = jwt.decode(token, _SECRET, algorithms=[_ALGORITHM], audience="agent-tool-runtime")
assert payload["mode"] == "chat"
def test_issue_automation_mode(self) -> None:
issuer = _make_issuer()
token = issuer.issue(owner_id=str(uuid4()), mode="automation")
payload = jwt.decode(token, _SECRET, algorithms=[_ALGORITHM], audience="agent-tool-runtime")
assert payload["mode"] == "automation"
def test_issue_rejects_unsupported_algorithm(self) -> None:
with pytest.raises(TokenValidationError, match="Unsupported"):
_make_issuer(jwt_algorithm="RS256")
class TestToolCredentialIssuerVerify:
def test_verify_returns_claims_for_valid_token(self) -> None:
issuer = _make_issuer()
owner_id = str(uuid4())
token = issuer.issue(owner_id=owner_id, mode="chat")
claims = issuer.verify(token)
assert claims["sub"] == owner_id
assert claims["purpose"] == "agent_tool_runtime"
def test_verify_rejects_expired_token(self) -> None:
issuer = _make_issuer(ttl_seconds=0)
token = issuer.issue(owner_id=str(uuid4()))
time.sleep(1)
with pytest.raises(TokenValidationError, match="expired"):
issuer.verify(token)
def test_verify_rejects_wrong_secret(self) -> None:
issuer = _make_issuer()
token = issuer.issue(owner_id=str(uuid4()))
wrong_issuer = _make_issuer(jwt_secret="wrong-secret")
with pytest.raises(TokenValidationError, match="signature"):
wrong_issuer.verify(token)
def test_verify_rejects_wrong_audience(self) -> None:
payload = {
"sub": str(uuid4()),
"aud": "wrong-audience",
"iss": _ISSUER,
"purpose": "agent_tool_runtime",
"exp": int(time.time()) + 600,
"iat": int(time.time()),
}
token = jwt.encode(payload, _SECRET, algorithm=_ALGORITHM)
issuer = _make_issuer()
with pytest.raises(TokenValidationError):
issuer.verify(token)
def test_verify_rejects_wrong_purpose(self) -> None:
payload = {
"sub": str(uuid4()),
"aud": "agent-tool-runtime",
"iss": _ISSUER,
"purpose": "wrong_purpose",
"exp": int(time.time()) + 600,
"iat": int(time.time()),
}
token = jwt.encode(payload, _SECRET, algorithm=_ALGORITHM)
issuer = _make_issuer()
with pytest.raises(TokenValidationError, match="purpose"):
issuer.verify(token)
def test_verify_rejects_wrong_issuer(self) -> None:
issuer_a = _make_issuer()
token = issuer_a.issue(owner_id=str(uuid4()))
issuer_b = _make_issuer(jwt_issuer="https://other.example.com/auth/v1")
with pytest.raises(TokenValidationError, match="issuer"):
issuer_b.verify(token)
def test_verify_rejects_malformed_token(self) -> None:
issuer = _make_issuer()
with pytest.raises(TokenValidationError, match="decode"):
issuer.verify("not-a-valid-jwt")
class TestToolCredentialContext:
def test_set_and_get_credential(self) -> None:
from core.auth.tool_credential_context import (
set_tool_credential,
reset_tool_credential,
get_tool_credential,
)
assert get_tool_credential() is None
token = set_tool_credential("test-credential")
assert get_tool_credential() == "test-credential"
reset_tool_credential(token)
assert get_tool_credential() is None
@@ -60,7 +60,7 @@ def _make_orm_job(
owner_id=owner_id or uuid4(),
title="Test Job",
config={
"enabled_tools": ["calendar.read", "user.lookup"],
"enabled_skills": ["calendar", "contacts"],
"context": {
"source": "latest_chat",
"window_mode": "day",
@@ -109,7 +109,7 @@ async def test_scan_and_dispatch_calls_dispatch_fn_with_runtime_config() -> None
assert dispatched_calls[0]["owner_id"] == owner_id
assert dispatched_calls[0]["runtime_config"] is not None
cfg: RuntimeConfig = dispatched_calls[0]["runtime_config"]
assert len(cfg.enabled_tools) == 2
assert len(cfg.enabled_skills) == 2
def test_compute_next_run_at_daily() -> None:
@@ -8,10 +8,7 @@ def test_memory_automation_static_config_contract() -> None:
assert config.context.window_mode.value == "day"
assert config.context.window_count == 2
assert [tool.value for tool in config.enabled_tools] == [
"memory.write",
"memory.forget",
]
assert [skill.value for skill in config.enabled_skills] == ["memory"]
assert config.input_template is not None
assert "回顾" in config.input_template
assert "遗忘" in config.input_template
@@ -1,90 +1,28 @@
from __future__ import annotations
from schemas.agent.runtime_models import RouterAgentOutput, WorkerAgentOutputRich
from schemas.agent.runtime_models import RouterAgentOutput, WorkerAgentOutputLite
def test_router_agent_output_coerces_key_entity_value_to_string() -> None:
def test_router_agent_output_parses_simplified_contract() -> None:
payload = {
"normalized_task_input": {
"user_text": "test",
"multimodal_summary": [],
"context_summary": "",
},
"key_entities": [
{
"name": "priority",
"type": "number",
"value": 8,
}
],
"constraints": [],
"task_typing": {
"primary": "planning",
"secondary": [],
},
"execution_mode": "onestep",
"result_typing": {
"primary": "summary",
"secondary": [],
},
"objective": "查询今天的日程安排",
"context_summary": "用户询问天气",
"requires_tool_evidence": True,
}
model = RouterAgentOutput.model_validate(payload)
assert model.key_entities[0].value == "8"
assert model.objective == "查询今天的日程安排"
assert model.requires_tool_evidence is True
def test_router_agent_output_coerces_constraint_value_to_string() -> None:
payload = {
"normalized_task_input": {
"user_text": "test",
"multimodal_summary": [],
"context_summary": "",
},
"key_entities": [],
"constraints": [
{
"key": "strict_mode",
"value": True,
"required": True,
}
],
"task_typing": {
"primary": "planning",
"secondary": [],
},
"execution_mode": "onestep",
"result_typing": {
"primary": "summary",
"secondary": [],
},
}
model = RouterAgentOutput.model_validate(payload)
assert model.constraints[0].value == "True"
def test_worker_agent_output_rich_accepts_list_item_status_object() -> None:
def test_worker_agent_output_lite_keeps_suggested_actions() -> None:
payload = {
"status": "success",
"answer": "done",
"result_type": "summary",
"ui_hints": {
"intent": "status",
"status": "info",
"title": "状态",
"listItems": [
{
"title": "任务A",
"status": {"type": "info", "value": "已归档"},
}
],
},
"suggested_actions": ["要不要我继续帮你查明天的安排?"],
}
model = WorkerAgentOutputRich.model_validate(payload)
model = WorkerAgentOutputLite.model_validate(payload)
assert model.ui_hints is not None
assert model.ui_hints.list_items[0].status is not None
assert model.ui_hints.list_items[0].status.value == "info"
assert model.suggested_actions == ["要不要我继续帮你查明天的安排?"]
@@ -5,27 +5,20 @@ import pytest
from schemas.agent.system_agent import SystemAgentLLMConfig
def test_system_agent_llm_config_normalizes_enabled_tools_aliases() -> None:
def test_system_agent_llm_config_normalizes_enabled_skills() -> None:
config = SystemAgentLLMConfig.model_validate(
{
"enabled_tools": [
"calendar.write",
"calendar_write",
"user.lookup",
]
"enabled_skills": ["calendar", "calendar", "contacts"]
}
)
assert [tool.value for tool in config.enabled_tools] == [
"calendar.write",
"user.lookup",
]
assert [skill.value for skill in config.enabled_skills] == ["calendar", "contacts"]
def test_system_agent_llm_config_rejects_unknown_enabled_tool() -> None:
with pytest.raises(ValueError, match="unknown enabled tool"):
def test_system_agent_llm_config_rejects_unknown_enabled_skill() -> None:
with pytest.raises(ValueError):
SystemAgentLLMConfig.model_validate(
{
"enabled_tools": ["calendar.remove"],
"enabled_skills": ["calendar.remove"],
}
)
@@ -9,8 +9,7 @@ from v1.auth.automation_static_config import load_static_automation_job_config
def test_memory_extraction_static_config_has_expected_defaults() -> None:
config = load_static_automation_job_config(config_name="memory_extraction")
assert "memory.write" in (config.enabled_tools or [])
assert "memory.forget" in (config.enabled_tools or [])
assert [skill.value for skill in (config.enabled_skills or [])] == ["memory"]
assert config.context is not None
assert config.context.source.value == "latest_chat"
assert config.schedule is not None
@@ -21,7 +20,7 @@ def test_automation_job_config_rejects_missing_weekdays_for_weekly() -> None:
with pytest.raises(ValueError, match="weekdays is required"):
AutomationJobConfig.model_validate(
{
"enabled_tools": ["calendar.read"],
"enabled_skills": ["calendar"],
"input_template": "x",
"context": {
"source": "latest_chat",
@@ -38,12 +38,12 @@ async def test_attachment_storage_rejects_unexpected_bucket(
storage = SupabaseService()
monkeypatch.setattr(
app_config.storage,
app_config.storage.attachment,
"bucket",
"allowed-bucket",
)
with pytest.raises(RuntimeError, match="Invalid attachment bucket"):
with pytest.raises(RuntimeError, match="Invalid storage bucket"):
await storage.upload_bytes(
bucket="other-bucket",
path="agent-inputs/u/t/r/file.png",
@@ -62,7 +62,7 @@ async def test_attachment_storage_accepts_configured_bucket(
fake_bucket = _FakeBucket()
fake_client = SimpleNamespace(storage=_FakeStorage(fake_bucket))
monkeypatch.setattr(
app_config.storage,
app_config.storage.attachment,
"bucket",
"allowed-bucket",
)
@@ -3,7 +3,7 @@ from __future__ import annotations
from uuid import uuid4
import pytest
from fastapi import HTTPException
from core.http.errors import ApiProblemError
from core.auth.models import CurrentUser
from v1.agent.service import ensure_session_owner
@@ -12,5 +12,5 @@ from v1.agent.service import ensure_session_owner
def test_owner_guard_denies_non_owner() -> None:
user = CurrentUser(id=uuid4(), phone="self@example.com")
with pytest.raises(HTTPException):
with pytest.raises(ApiProblemError):
ensure_session_owner(owner_id="other-user", current_user=user)
+17 -19
View File
@@ -6,10 +6,10 @@ from urllib.parse import quote
from uuid import UUID
from ag_ui.core import RunAgentInput
from fastapi import HTTPException
import pytest
import v1.agent.service as agent_service_module
from core.http.errors import ApiProblemError
from core.auth.models import CurrentUser
from core.config.settings import config
from schemas.domain.chat_message import AgentChatMessageMetadata
@@ -25,7 +25,7 @@ class _FakeRepository:
async def get_session_owner(self, *, session_id: str) -> str:
if session_id == "00000000-0000-0000-0000-000000000001":
return "00000000-0000-0000-0000-000000000001"
raise HTTPException(status_code=404, detail="Session not found")
raise ApiProblemError(status_code=404, detail="Session not found")
async def create_session_for_user(
self, *, user_id: str, session_id: str | None = None
@@ -92,7 +92,7 @@ class _FakeRepository:
"timeout_seconds": 30,
"visibility_consumer_bit": bit,
"context_messages": {"mode": "number", "count": 20},
"enabled_tools": [],
"enabled_skills": [],
},
}
@@ -201,7 +201,7 @@ def _build_run_input(*, urls: list[str], runtime_mode: str = "chat") -> RunAgent
@pytest.mark.asyncio
async def test_enqueue_run_rejects_non_project_host_signed_url(monkeypatch) -> None:
monkeypatch.setattr(
agent_service_module.config.storage, "bucket", "agent-test-bucket"
agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket"
)
service = AgentService(
repository=_FakeRepository(),
@@ -215,11 +215,11 @@ async def test_enqueue_run_rejects_non_project_host_signed_url(monkeypatch) -> N
]
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.enqueue_run(run_input=run_input, current_user=_user())
assert exc_info.value.status_code == 422
assert exc_info.value.detail == "INVALID_BINARY_URL_HOST"
assert exc_info.value.detail == "Invalid binary url host"
@pytest.mark.asyncio
@@ -227,7 +227,7 @@ async def test_enqueue_run_persists_attachment_and_queue_without_user_token(
monkeypatch,
) -> None:
monkeypatch.setattr(
agent_service_module.config.storage, "bucket", "agent-test-bucket"
agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket"
)
repository = _FakeRepository()
queue = _FakeQueue()
@@ -274,7 +274,7 @@ async def test_enqueue_run_persists_attachment_and_queue_without_user_token(
@pytest.mark.asyncio
async def test_enqueue_run_rejects_unknown_agent_type(monkeypatch) -> None:
monkeypatch.setattr(
agent_service_module.config.storage, "bucket", "agent-test-bucket"
agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket"
)
service = AgentService(
repository=_FakeRepository(),
@@ -294,7 +294,7 @@ async def test_enqueue_run_rejects_unknown_agent_type(monkeypatch) -> None:
runtime_mode="planner",
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.enqueue_run(run_input=run_input, current_user=_user())
assert exc_info.value.status_code == 422
@@ -303,7 +303,7 @@ async def test_enqueue_run_rejects_unknown_agent_type(monkeypatch) -> None:
@pytest.mark.asyncio
async def test_enqueue_run_rejects_invalid_runtime_mode(monkeypatch) -> None:
monkeypatch.setattr(
agent_service_module.config.storage, "bucket", "agent-test-bucket"
agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket"
)
repository = _FakeRepository()
service = AgentService(
@@ -314,7 +314,7 @@ async def test_enqueue_run_rejects_invalid_runtime_mode(monkeypatch) -> None:
)
run_input = _build_run_input(urls=[], runtime_mode="planner")
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.enqueue_run(run_input=run_input, current_user=_user())
assert exc_info.value.status_code == 422
@@ -324,7 +324,7 @@ async def test_enqueue_run_rejects_invalid_runtime_mode(monkeypatch) -> None:
@pytest.mark.asyncio
async def test_create_attachment_signed_url_returns_url(monkeypatch) -> None:
monkeypatch.setattr(
agent_service_module.config.storage, "bucket", "agent-test-bucket"
agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket"
)
service = AgentService(
repository=_FakeRepository(),
@@ -349,7 +349,7 @@ async def test_create_attachment_signed_url_rejects_out_of_scope_path(
monkeypatch,
) -> None:
monkeypatch.setattr(
agent_service_module.config.storage, "bucket", "agent-test-bucket"
agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket"
)
service = AgentService(
repository=_FakeRepository(),
@@ -358,7 +358,7 @@ async def test_create_attachment_signed_url_rejects_out_of_scope_path(
attachment_storage=_FakeAttachmentStorage(),
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.create_attachment_signed_url(
bucket="agent-test-bucket",
path="agent-inputs/other-user/thread-x/uploads/a.png",
@@ -371,7 +371,7 @@ async def test_create_attachment_signed_url_rejects_out_of_scope_path(
@pytest.mark.asyncio
async def test_enqueue_run_rejects_too_many_attachments(monkeypatch) -> None:
monkeypatch.setattr(
agent_service_module.config.storage, "bucket", "agent-test-bucket"
agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket"
)
service = AgentService(
repository=_FakeRepository(),
@@ -405,7 +405,7 @@ async def test_enqueue_run_rejects_too_many_attachments(monkeypatch) -> None:
]
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.enqueue_run(run_input=run_input, current_user=_user())
assert exc_info.value.status_code == 422
@@ -461,8 +461,6 @@ async def test_get_history_snapshot_filters_out_tool_messages() -> None:
"agent_output": {
"status": "success",
"answer": "今天共有 3 条日程。",
"key_points": [],
"result_type": "summary",
"suggested_actions": [],
},
},
@@ -529,7 +527,7 @@ async def test_cancel_run_rejects_non_owner() -> None:
phone="+8613812340000",
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.cancel_run(
thread_id="00000000-0000-0000-0000-000000000001",
run_id="run-cancel-2",
+2 -3
View File
@@ -30,7 +30,7 @@ def test_convert_message_to_history_does_not_attach_ui_schema_for_tool_message()
assert "uiSchema" not in result
def test_convert_message_to_history_uses_ui_schema_key_for_assistant_message() -> None:
def test_convert_message_to_history_does_not_attach_ui_schema_for_assistant_message() -> None:
message = _FakeMessage(
role="assistant",
metadata={
@@ -40,9 +40,8 @@ def test_convert_message_to_history_uses_ui_schema_key_for_assistant_message() -
result = convert_message_to_history(message) # type: ignore[arg-type]
assert "ui_schema" in result
assert "ui_schema" not in result
assert "uiSchema" not in result
assert result["ui_schema"] == {"version": "2.0", "root": {"type": "stack"}}
def test_convert_message_to_history_returns_multiple_user_attachments() -> None:
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock
import pytest
from core.http.errors import ApiProblemError
from core.config.settings import config
from v1.auth.gateway import SupabaseAuthGateway
from v1.auth.schemas import (
@@ -28,6 +29,7 @@ class TestSupabaseAuthGateway:
"v1.auth.gateway.supabase_service.get_admin_client",
lambda: mock_admin_client,
)
monkeypatch.setattr(config.runtime, "environment", "test")
return SupabaseAuthGateway(), mock_client, mock_admin_client
@pytest.mark.asyncio
@@ -4,8 +4,8 @@ from uuid import uuid4
import pytest
from models.automation_jobs import AutomationJobStatus, ScheduleType
from schemas.agent.skill_config import SkillName
from schemas.domain.automation import (
AgentTool,
AutomationJobConfig,
ContextSource,
ContextWindowMode,
@@ -23,7 +23,7 @@ from v1.automation_jobs.schemas import (
def _make_config() -> AutomationJobConfig:
return AutomationJobConfig(
input_template="Hello",
enabled_tools=[AgentTool.MEMORY_WRITE],
enabled_skills=[SkillName.MEMORY],
context=MessageContextConfig(
source=ContextSource.LATEST_CHAT,
window_mode=ContextWindowMode.DAY,
@@ -119,7 +119,7 @@ async def test_update_merges_config_and_recomputes_next_run() -> None:
existing_job.timezone = "UTC"
existing_job.config = {
"input_template": "Old",
"enabled_tools": ["memory.write"],
"enabled_skills": ["memory"],
"context": {
"source": "latest_chat",
"window_mode": "day",
@@ -136,7 +136,7 @@ async def test_update_merges_config_and_recomputes_next_run() -> None:
data = AutomationJobUpdateRequest(
config=AutomationJobConfig(
enabled_tools=[AgentTool.MEMORY_WRITE, AgentTool.MEMORY_FORGET],
enabled_skills=[SkillName.MEMORY],
schedule=ScheduleConfig(
type=ScheduleType.WEEKLY,
run_at=ScheduleRunAt(hour=10, minute=30),
@@ -150,8 +150,8 @@ async def test_update_merges_config_and_recomputes_next_run() -> None:
update_values = repository.update_by_id.call_args[0][1]
assert "config" in update_values
assert "next_run_at" in update_values
enabled_tools = update_values["config"]["enabled_tools"]
assert isinstance(enabled_tools[0], str)
enabled_skills = update_values["config"]["enabled_skills"]
assert isinstance(enabled_skills[0], str)
@pytest.mark.asyncio
@@ -5,7 +5,7 @@ from uuid import uuid4
import pytest
from pydantic import ValidationError
from core.agentscope.tools.tool_config import AgentTool
from schemas.agent.skill_config import SkillName
from schemas.domain.automation import AutomationJobConfig
from v1.automation_jobs.schemas import (
AutomationJobCreateRequest,
@@ -22,7 +22,7 @@ def _mock_orm_job() -> MagicMock:
mock_orm_job.title = "Test Job"
mock_orm_job.config = {
"input_template": "Hello",
"enabled_tools": ["memory.write", "memory.forget"],
"enabled_skills": ["memory"],
"context": {
"source": "latest_chat",
"window_mode": "day",
@@ -74,7 +74,7 @@ def test_create_request_valid_timezone() -> None:
"timezone": "Asia/Shanghai",
"config": {
"input_template": "Hello",
"enabled_tools": ["memory.write"],
"enabled_skills": ["memory"],
"context": {
"source": "latest_chat",
"window_mode": "day",
@@ -102,7 +102,7 @@ def test_update_timezone_validation() -> None:
def test_config_patch_still_allows_partial_payload() -> None:
patch = AutomationJobConfig.model_validate(
{"enabled_tools": [AgentTool.MEMORY_WRITE]}
{"enabled_skills": [SkillName.MEMORY]}
)
assert patch.input_template is None
assert patch.enabled_tools == [AgentTool.MEMORY_WRITE]
assert patch.enabled_skills == [SkillName.MEMORY]
@@ -4,9 +4,18 @@ from uuid import UUID, uuid4
import pytest
from sqlalchemy.exc import SQLAlchemyError
from core.http.errors import ApiProblemError
from core.http.errors import ApiProblemError
from models.automation_jobs import AutomationJobStatus, ScheduleType
from schemas.agent.skill_config import SkillName
from schemas.domain.automation import (
AutomationJobConfig,
ContextSource,
ContextWindowMode,
MessageContextConfig,
ScheduleConfig,
ScheduleRunAt,
)
from v1.automation_jobs.service import (
AutomationJobLimitExceeded,
AutomationJobNotFound,
@@ -17,21 +26,12 @@ from v1.automation_jobs.schemas import (
AutomationJobCreateRequest,
AutomationJobUpdateRequest,
)
from schemas.domain.automation import (
AgentTool,
AutomationJobConfig,
ContextSource,
ContextWindowMode,
MessageContextConfig,
ScheduleConfig,
ScheduleRunAt,
)
def _make_config() -> AutomationJobConfig:
return AutomationJobConfig(
input_template="Hello",
enabled_tools=[AgentTool.MEMORY_WRITE],
enabled_skills=[SkillName.MEMORY],
context=MessageContextConfig(
source=ContextSource.LATEST_CHAT,
window_mode=ContextWindowMode.DAY,
@@ -65,7 +65,7 @@ def _make_job(
job.status = AutomationJobStatus.ACTIVE
job.config = {
"input_template": "Hello",
"enabled_tools": ["memory.write"],
"enabled_skills": ["memory"],
"context": {
"source": "latest_chat",
"window_mode": "day",
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
from core.http.errors import ApiProblemError
from core.auth.models import CurrentUser
from models.inbox_messages import InboxMessage, InboxMessageStatus
@@ -132,7 +132,7 @@ async def test_accept_subscription_not_found(
inbox_repository=FakeInboxRepo(None),
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.accept_subscription(item_id)
assert exc_info.value.status_code == 404
@@ -177,7 +177,7 @@ async def test_reject_subscription_not_found(
inbox_repository=FakeInboxRepo(None),
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.reject_subscription(item_id)
assert exc_info.value.status_code == 404