refactor: unify skills+cli runtime and streamline ag-ui flow
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user