feat: 增强日历功能并集成 AgentScope 代理服务

This commit is contained in:
qzl
2026-03-11 17:16:11 +08:00
parent e20e7d2a02
commit 85b314cf64
53 changed files with 3642 additions and 297 deletions
@@ -0,0 +1,42 @@
from __future__ import annotations
from core.agentscope.events.agui_codec import to_agui_wire_event
def test_maps_internal_text_delta_to_agui_wire_event() -> None:
internal = {
"id": "e1",
"type": "text.delta",
"threadId": "t1",
"runId": "r1",
"data": {"delta": "hel"},
}
result = to_agui_wire_event(internal)
assert result["type"] == "TEXT_MESSAGE_CONTENT"
assert result["threadId"] == "t1"
assert result["runId"] == "r1"
assert result["delta"] == "hel"
def test_reserved_keys_in_data_cannot_override_wire_fields() -> None:
internal = {
"id": "e2",
"type": "run.started",
"threadId": "thread-1",
"runId": "run-1",
"data": {
"type": "RUN_ERROR",
"threadId": "thread-override",
"runId": "run-override",
"message": "ok",
},
}
result = to_agui_wire_event(internal)
assert result["type"] == "RUN_STARTED"
assert result["threadId"] == "thread-1"
assert result["runId"] == "run-1"
assert result["message"] == "ok"
@@ -0,0 +1,32 @@
from __future__ import annotations
import pytest
from core.agentscope.events.pipeline import AgentScopeEventPipeline
@pytest.mark.asyncio
async def test_pipeline_orders_codec_persist_publish() -> None:
calls: list[str] = []
class _Codec:
def to_wire(self, event: dict[str, object]) -> dict[str, object]:
calls.append("codec")
return {"type": "RUN_STARTED", **event}
class _Store:
async def persist(self, event: dict[str, object]) -> None:
calls.append("persist")
assert event["type"] == "RUN_STARTED"
class _Bus:
async def publish(self, *, session_id: str, event: dict[str, object]) -> str:
calls.append("publish")
assert session_id == "thread-1"
return "1-0"
pipeline = AgentScopeEventPipeline(codec=_Codec(), store=_Store(), bus=_Bus())
cursor = await pipeline.emit(session_id="thread-1", event={"id": "evt-1"})
assert cursor == "1-0"
assert calls == ["codec", "persist", "publish"]
@@ -0,0 +1,71 @@
from __future__ import annotations
from core.agentscope.events.redis_bus import RedisStreamBus
class _FakeRedis:
def __init__(self) -> None:
self._rows: list[tuple[str, str]] = []
def xadd(self, _stream: str, fields: dict[str, str]) -> str:
cursor = f"{len(self._rows) + 1}-0"
self._rows.append((cursor, fields["event"]))
return cursor
def xread(
self,
streams: dict[str, str],
count: int,
block: int,
) -> list[tuple[str, list[tuple[str, dict[str, str]]]]]:
del count, block
stream_name, last = next(iter(streams.items()))
rows: list[tuple[str, dict[str, str]]] = []
for cursor, payload in self._rows:
if cursor > last:
rows.append((cursor, {"event": payload}))
return [(stream_name, rows)]
class _FakeRedisBytes:
def __init__(self) -> None:
self._rows: list[tuple[str, str]] = []
def xadd(self, _stream: str, fields: dict[str, str]) -> str:
cursor = f"{len(self._rows) + 1}-0"
self._rows.append((cursor, fields["event"]))
return cursor
def xread(
self,
streams: dict[str, str],
count: int,
block: int,
) -> list[tuple[str, list[tuple[str, dict[str, bytes]]]]]:
del count, block
stream_name, last = next(iter(streams.items()))
rows: list[tuple[str, dict[str, bytes]]] = []
for cursor, payload in self._rows:
if cursor > last:
rows.append((cursor, {"event": payload.encode("utf-8")}))
return [(stream_name, rows)]
async def test_publish_then_read_after_cursor() -> None:
bus = RedisStreamBus(client=_FakeRedis(), stream_prefix="agent.events")
first_cursor = await bus.publish(
session_id="thread-1", event={"type": "RUN_STARTED"}
)
await bus.publish(session_id="thread-1", event={"type": "RUN_FINISHED"})
rows = await bus.read(session_id="thread-1", last_event_id=first_cursor)
assert len(rows) == 1
assert rows[0]["event"]["type"] == "RUN_FINISHED"
async def test_read_supports_bytes_payload() -> None:
bus = RedisStreamBus(client=_FakeRedisBytes(), stream_prefix="agent.events")
await bus.publish(session_id="thread-1", event={"type": "RUN_STARTED"})
rows = await bus.read(session_id="thread-1", last_event_id=None)
assert rows[0]["event"]["type"] == "RUN_STARTED"
@@ -0,0 +1,25 @@
from __future__ import annotations
import json
from core.agentscope.events.sse import to_sse_event
def test_sse_frame_contains_event_and_json_payload() -> None:
payload = {"type": "RUN_STARTED", "threadId": "t1", "runId": "r1"}
frame = to_sse_event("1-0", payload)
assert frame.startswith("id: 1-0\n")
assert "event: RUN_STARTED\n" in frame
assert frame.endswith("\n\n")
data_line = [line for line in frame.splitlines() if line.startswith("data: ")][0]
parsed = json.loads(data_line[len("data: ") :])
assert parsed["threadId"] == "t1"
def test_sse_sanitizes_stream_id_newlines() -> None:
payload = {"type": "RUN_STARTED"}
frame = to_sse_event("1-0\nmalicious: yes", payload)
assert frame.startswith("id: 1-0malicious: yes\n")
@@ -0,0 +1,139 @@
from __future__ import annotations
from typing import Any, cast
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.agent.domain.user_context import UserAgentContext, parse_profile_settings
from core.agentscope.runtime.agent_route_runtime import AgentRouteRuntime
from core.agentscope.schemas import ReportOutput, RuntimeOutput
from core.agentscope.schemas.agent_runtime import RunCommand
from core.agentscope.schemas.execution import ExecutionBatchOutput
from core.agentscope.schemas.intent import IntentOutput
def _user_context() -> UserAgentContext:
return UserAgentContext(
user_id=uuid4(),
username="tester",
bio=None,
settings=parse_profile_settings(
{
"version": 1,
"preferences": {
"interface_language": "zh-CN",
"ai_language": "zh-CN",
"timezone": "Asia/Shanghai",
"country": "CN",
},
}
),
)
@pytest.mark.asyncio
async def test_runtime_emits_started_text_and_finished_events() -> None:
calls: list[dict[str, Any]] = []
class _FakePipeline:
async def emit(self, *, session_id: str, event: dict[str, object]) -> str:
assert session_id == "thread-1"
calls.append(event)
return f"{len(calls)}-0"
class _FakeOrchestrator:
async def run(self, **_: object) -> RuntimeOutput:
return RuntimeOutput(
intent=IntentOutput(
route="DIRECT_RESPONSE",
intent_summary="summary",
direct_response="done",
tasks=[],
complexity="simple",
),
execution=ExecutionBatchOutput(
task_results=[],
overall_status="SUCCESS",
aggregate_summary="ok",
),
report=ReportOutput(
assistant_text="hello world",
response_metadata={},
),
)
runtime = AgentRouteRuntime(
orchestrator=_FakeOrchestrator(), pipeline=_FakePipeline()
)
command = RunCommand(threadId="thread-1", runId="run-1", messages=[])
await runtime.run(
command=command,
owner_id=uuid4(),
user_token="token",
user_context=_user_context(),
session=cast(AsyncSession, object()),
)
assert [item["type"] for item in calls] == [
"run.started",
"step.start",
"step.finish",
"step.start",
"step.finish",
"step.start",
"text.start",
"text.delta",
"text.end",
"step.finish",
"run.finished",
]
assert calls[1]["data"]["stepName"] == "intent"
assert calls[2]["data"]["stepName"] == "intent"
assert calls[3]["data"]["stepName"] == "execution"
assert calls[4]["data"]["stepName"] == "execution"
assert calls[5]["data"]["stepName"] == "report"
assert calls[7]["data"]["delta"] == "hello world"
assert calls[6]["data"]["messageId"] == calls[7]["data"]["messageId"]
assert calls[7]["data"]["messageId"] == calls[8]["data"]["messageId"]
assert calls[9]["data"]["stepName"] == "report"
@pytest.mark.asyncio
async def test_runtime_emits_run_error_when_orchestrator_fails() -> None:
calls: list[dict[str, Any]] = []
class _FakePipeline:
async def emit(self, *, session_id: str, event: dict[str, object]) -> str:
assert session_id == "thread-1"
calls.append(event)
return f"{len(calls)}-0"
class _FailOrchestrator:
async def run(self, **_: object) -> RuntimeOutput:
raise RuntimeError("boom")
runtime = AgentRouteRuntime(
orchestrator=_FailOrchestrator(),
pipeline=_FakePipeline(),
)
command = RunCommand(threadId="thread-1", runId="run-1", messages=[])
with pytest.raises(RuntimeError, match="boom"):
await runtime.run(
command=command,
owner_id=uuid4(),
user_token="token",
user_context=_user_context(),
session=cast(AsyncSession, object()),
)
assert [item["type"] for item in calls] == [
"run.started",
"step.start",
"run.error",
]
assert calls[1]["data"]["stepName"] == "intent"
assert calls[2]["data"]["message"] == "runtime execution failed"
@@ -0,0 +1,138 @@
from __future__ import annotations
from typing import Any
from uuid import uuid4
import pytest
import core.agentscope.runtime.tasks as tasks_module
def _run_input_payload() -> dict[str, Any]:
return {
"threadId": str(uuid4()),
"runId": "run-1",
"messages": [],
"tools": [],
"context": {},
"forwardedProps": {},
}
class _FakeSessionCtx:
async def __aenter__(self) -> object:
return object()
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
del exc_type, exc, tb
@pytest.mark.asyncio
async def test_run_agentscope_task_calls_runtime_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
called: dict[str, int] = {"run": 0, "resume": 0}
class _FakeRuntime:
def __init__(self, **kwargs: object) -> None:
del kwargs
async def run(self, **kwargs: object) -> object:
del kwargs
called["run"] += 1
return object()
async def resume(self, **kwargs: object) -> object:
del kwargs
called["resume"] += 1
return object()
async def _fake_get_redis_client() -> object:
return object()
monkeypatch.setattr(tasks_module, "AgentRouteRuntime", _FakeRuntime)
monkeypatch.setattr(
tasks_module,
"get_or_init_redis_client",
_fake_get_redis_client,
)
monkeypatch.setattr(tasks_module, "AsyncSessionLocal", lambda: _FakeSessionCtx())
result = await tasks_module.run_agentscope_task(
{
"command": "run",
"owner_id": str(uuid4()),
"run_input": _run_input_payload(),
}
)
assert result["status"] == "completed"
assert called["run"] == 1
assert called["resume"] == 0
@pytest.mark.asyncio
async def test_run_agentscope_task_calls_runtime_resume(
monkeypatch: pytest.MonkeyPatch,
) -> None:
called: dict[str, int] = {"run": 0, "resume": 0}
class _FakeRuntime:
def __init__(self, **kwargs: object) -> None:
del kwargs
async def run(self, **kwargs: object) -> object:
del kwargs
called["run"] += 1
return object()
async def resume(self, **kwargs: object) -> object:
del kwargs
called["resume"] += 1
return object()
async def _fake_get_redis_client() -> object:
return object()
monkeypatch.setattr(tasks_module, "AgentRouteRuntime", _FakeRuntime)
monkeypatch.setattr(
tasks_module,
"get_or_init_redis_client",
_fake_get_redis_client,
)
monkeypatch.setattr(tasks_module, "AsyncSessionLocal", lambda: _FakeSessionCtx())
result = await tasks_module.run_agentscope_task(
{
"command": "resume",
"owner_id": str(uuid4()),
"run_input": _run_input_payload(),
}
)
assert result["status"] == "completed"
assert called["run"] == 0
assert called["resume"] == 1
@pytest.mark.asyncio
async def test_run_agentscope_task_requires_owner_id() -> None:
with pytest.raises(ValueError, match="owner_id is required"):
await tasks_module.run_agentscope_task(
{
"command": "run",
"run_input": _run_input_payload(),
}
)
@pytest.mark.asyncio
async def test_run_agentscope_task_rejects_invalid_command_type() -> None:
with pytest.raises(ValueError, match="invalid command type"):
await tasks_module.run_agentscope_task(
{
"command": "unknown",
"owner_id": str(uuid4()),
"run_input": _run_input_payload(),
}
)
@@ -0,0 +1,106 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from core.agentscope import schemas as exported_schemas
from core.agentscope.schemas.agent_runtime import (
AcceptedTaskResponse,
AgUiWireEvent,
HistorySnapshot,
HistorySnapshotResponse,
InternalRuntimeEvent,
ResumeCommand,
RunCommand,
)
def test_run_command_alias_roundtrip() -> None:
payload = {
"threadId": "thread-001",
"runId": "run-001",
"state": {"cursor": 1},
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"name": "calendar.lookup"}],
"context": {"locale": "zh-CN"},
"forwardedProps": {"traceId": "trace-1"},
}
command = RunCommand.model_validate(payload)
assert command.thread_id == "thread-001"
assert command.run_id == "run-001"
assert command.forwarded_props == {"traceId": "trace-1"}
dumped = command.model_dump(mode="json", by_alias=True)
assert dumped["threadId"] == "thread-001"
assert dumped["runId"] == "run-001"
assert dumped["forwardedProps"] == {"traceId": "trace-1"}
def test_history_snapshot_response_shape() -> None:
response = HistorySnapshotResponse(
threadId="thread-123",
snapshot=HistorySnapshot(
threadId="thread-123",
day="2026-03-11",
hasMore=False,
messages=[{"id": "msg-1"}],
),
)
dumped = response.model_dump(mode="json", by_alias=True, exclude_none=True)
assert dumped["type"] == "STATE_SNAPSHOT"
assert dumped["threadId"] == "thread-123"
assert dumped["snapshot"]["scope"] == "history_day"
assert dumped["snapshot"]["hasMore"] is False
assert dumped["snapshot"]["messages"] == [{"id": "msg-1"}]
def test_runtime_event_validation_basics() -> None:
internal = InternalRuntimeEvent(type="RUN_STARTED", data={"step": 1})
assert internal.type == "RUN_STARTED"
assert internal.model_dump(mode="json", by_alias=True)["data"] == {"step": 1}
wire = AgUiWireEvent(type="TEXT_MESSAGE_CONTENT", payload={"delta": "hello"})
dumped = wire.model_dump(mode="json", by_alias=True, exclude_none=True)
assert dumped["type"] == "TEXT_MESSAGE_CONTENT"
assert dumped["payload"] == {"delta": "hello"}
with pytest.raises(ValidationError):
InternalRuntimeEvent.model_validate({"threadId": "t-1", "data": {}})
with pytest.raises(ValidationError):
AgUiWireEvent.model_validate({"payload": {"delta": "hello"}})
def test_task_response_and_resume_aliases() -> None:
accepted = AcceptedTaskResponse(
taskId="task-1",
threadId="thread-1",
runId="run-1",
created=False,
)
dumped = accepted.model_dump(mode="json", by_alias=True)
assert dumped["taskId"] == "task-1"
assert dumped["threadId"] == "thread-1"
assert dumped["runId"] == "run-1"
resumed = ResumeCommand.model_validate(
{
"threadId": "thread-1",
"runId": "run-2",
"messages": [],
"tools": [],
"context": {},
}
)
assert resumed.thread_id == "thread-1"
assert resumed.run_id == "run-2"
def test_schemas_exports_include_task_and_history_models() -> None:
assert exported_schemas.AcceptedTaskResponse is AcceptedTaskResponse
assert exported_schemas.TaskAccepted is AcceptedTaskResponse
assert exported_schemas.TaskAcceptedResponse is AcceptedTaskResponse
assert exported_schemas.HistorySnapshotResponse is HistorySnapshotResponse
@@ -131,3 +131,50 @@ async def test_calendar_write_rejects_event_id_for_create(
assert result["data"]["ok"] is False
assert result["data"]["code"] == "INVALID_ARGUMENT"
@pytest.mark.asyncio
async def test_calendar_write_maps_reminder_minutes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
captured.update(cast(dict[str, object], kwargs["tool_args"]))
return {"type": "calendar_card.v1", "version": "v1", "data": {"ok": True}}
monkeypatch.setattr(
calendar_module,
"_execute_mutate_calendar_event",
_fake_execute,
)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
await calendar_module.calendar_write(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
operation="create",
reminder_minutes=15,
)
assert captured["reminderMinutes"] == 15
@pytest.mark.asyncio
async def test_calendar_write_rejects_invalid_reminder_minutes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
result = await calendar_module.calendar_write(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
operation="create",
reminder_minutes=10081,
)
assert result["data"]["ok"] is False
assert result["data"]["code"] == "INVALID_ARGUMENT"