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
|
||||
Reference in New Issue
Block a user