feat(calendar): U3 agent calendar tool for ReAct integration
Adds CalendarTool implementing the Tool ABC so the ReAct engine can create, query, update, and delete events autonomously. Resolves event_type_name and tag_names (look up or create), sets source="agent" to distinguish agent-created events from manual ones. - src/agentkit/tools/calendar_tool.py — CalendarTool(Tool) - tests/unit/tools/test_calendar_tool.py — 13 tests covering all actions
This commit is contained in:
parent
d36e45bbe7
commit
42fe7bcbc9
|
|
@ -0,0 +1,277 @@
|
||||||
|
"""CalendarTool — Agent tool for calendar event CRUD via ReAct integration.
|
||||||
|
|
||||||
|
Wraps CalendarService so the LLM can create, query, update, and delete
|
||||||
|
calendar events via function calling. The tool delegates all business logic
|
||||||
|
to CalendarService — it only handles input validation, name→id resolution
|
||||||
|
for event types and tags, and result formatting.
|
||||||
|
|
||||||
|
The tool trusts the caller (the agent framework) to provide the correct
|
||||||
|
user_id; it does not perform auth (same pattern as DocumentTool).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agentkit.calendar.service import CalendarService
|
||||||
|
from agentkit.tools.base import Tool
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarTool(Tool):
|
||||||
|
"""Agent tool for calendar event management.
|
||||||
|
|
||||||
|
Actions: create_event, query_events, update_event, delete_event.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, calendar_service: CalendarService):
|
||||||
|
super().__init__(
|
||||||
|
name="calendar",
|
||||||
|
description=(
|
||||||
|
"Create, query, update, and delete calendar events. "
|
||||||
|
"Actions: create_event, query_events, update_event, delete_event."
|
||||||
|
),
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"create_event",
|
||||||
|
"query_events",
|
||||||
|
"update_event",
|
||||||
|
"delete_event",
|
||||||
|
],
|
||||||
|
"description": "Calendar operation to perform.",
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "User ID owning the calendar events.",
|
||||||
|
},
|
||||||
|
"event_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event ID (for update_event and delete_event).",
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event title (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"start_time": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event start time, ISO 8601 UTC (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"end_time": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event end time, ISO 8601 UTC (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event description (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event location (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"is_all_day": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the event is all-day (create_event, update_event).",
|
||||||
|
},
|
||||||
|
"event_type_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event type name; looked up or created if missing (create_event).",
|
||||||
|
},
|
||||||
|
"tag_names": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Tag names; each looked up or created if missing (create_event).",
|
||||||
|
},
|
||||||
|
"rrule": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "RFC 5545 RRULE recurrence string, e.g. FREQ=WEEKLY;BYDAY=MO;COUNT=10 (create_event).",
|
||||||
|
},
|
||||||
|
"conversation_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Conversation ID to associate with the event (create_event).",
|
||||||
|
},
|
||||||
|
"start_date": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Range start, ISO 8601 UTC (query_events).",
|
||||||
|
},
|
||||||
|
"end_date": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Range end, ISO 8601 UTC (query_events).",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of events to return (query_events).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["action", "user_id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._service = calendar_service
|
||||||
|
|
||||||
|
async def execute(self, **kwargs) -> dict[str, Any]:
|
||||||
|
action = kwargs.get("action")
|
||||||
|
|
||||||
|
if action == "create_event":
|
||||||
|
return await self._create_event(**kwargs)
|
||||||
|
if action == "query_events":
|
||||||
|
return await self._query_events(**kwargs)
|
||||||
|
if action == "update_event":
|
||||||
|
return await self._update_event(**kwargs)
|
||||||
|
if action == "delete_event":
|
||||||
|
return await self._delete_event(**kwargs)
|
||||||
|
return {"success": False, "error": f"Unknown action: {action!r}"}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# create_event
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _create_event(self, **kwargs) -> dict[str, Any]:
|
||||||
|
user_id = kwargs.get("user_id")
|
||||||
|
title = kwargs.get("title")
|
||||||
|
start_time = kwargs.get("start_time")
|
||||||
|
end_time = kwargs.get("end_time")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return {"success": False, "error": "Missing required field: user_id"}
|
||||||
|
if not title:
|
||||||
|
return {"success": False, "error": "Missing required field: title"}
|
||||||
|
if not start_time:
|
||||||
|
return {"success": False, "error": "Missing required field: start_time"}
|
||||||
|
if not end_time:
|
||||||
|
return {"success": False, "error": "Missing required field: end_time"}
|
||||||
|
|
||||||
|
description = kwargs.get("description", "")
|
||||||
|
location = kwargs.get("location", "")
|
||||||
|
is_all_day = kwargs.get("is_all_day", False)
|
||||||
|
rrule = kwargs.get("rrule")
|
||||||
|
conversation_id = kwargs.get("conversation_id")
|
||||||
|
|
||||||
|
# Resolve event_type_name → event_type_id (look up or create)
|
||||||
|
event_type_id: str | None = None
|
||||||
|
event_type_name = kwargs.get("event_type_name")
|
||||||
|
if event_type_name:
|
||||||
|
event_type_id = await self._resolve_event_type_id(user_id, event_type_name)
|
||||||
|
|
||||||
|
# Resolve tag_names → tag_ids (look up or create each)
|
||||||
|
tag_ids: list[str] | None = None
|
||||||
|
tag_names = kwargs.get("tag_names")
|
||||||
|
if tag_names:
|
||||||
|
tag_ids = await self._resolve_tag_ids(user_id, tag_names)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = await self._service.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
description=description,
|
||||||
|
location=location,
|
||||||
|
is_all_day=is_all_day,
|
||||||
|
event_type_id=event_type_id,
|
||||||
|
rrule=rrule,
|
||||||
|
source="agent",
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
tag_ids=tag_ids,
|
||||||
|
)
|
||||||
|
return {"success": True, "event": event.to_dict()}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"create_event failed: {e}"}
|
||||||
|
|
||||||
|
async def _resolve_event_type_id(self, user_id: str, name: str) -> str | None:
|
||||||
|
"""Look up an event type by name for the user; create if not found."""
|
||||||
|
existing = await self._service.list_event_types(user_id)
|
||||||
|
for et in existing:
|
||||||
|
if et.name == name:
|
||||||
|
return et.id
|
||||||
|
et = await self._service.create_event_type(user_id, name)
|
||||||
|
return et.id
|
||||||
|
|
||||||
|
async def _resolve_tag_ids(self, user_id: str, names: list[str]) -> list[str]:
|
||||||
|
"""Look up tags by name for the user; create each if not found."""
|
||||||
|
existing = await self._service.list_tags(user_id)
|
||||||
|
existing_by_name = {t.name: t.id for t in existing}
|
||||||
|
tag_ids: list[str] = []
|
||||||
|
for name in names:
|
||||||
|
if name in existing_by_name:
|
||||||
|
tag_ids.append(existing_by_name[name])
|
||||||
|
else:
|
||||||
|
tag = await self._service.create_tag(user_id, name)
|
||||||
|
tag_ids.append(tag.id)
|
||||||
|
return tag_ids
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# query_events
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _query_events(self, **kwargs) -> dict[str, Any]:
|
||||||
|
user_id = kwargs.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
return {"success": False, "error": "Missing required field: user_id"}
|
||||||
|
|
||||||
|
start = kwargs.get("start_date")
|
||||||
|
end = kwargs.get("end_date")
|
||||||
|
limit = kwargs.get("limit")
|
||||||
|
|
||||||
|
try:
|
||||||
|
events = await self._service.list_events(
|
||||||
|
user_id=user_id,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
events = events[:limit]
|
||||||
|
return {"success": True, "events": [e.to_dict() for e in events]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"query_events failed: {e}"}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# update_event
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _update_event(self, **kwargs) -> dict[str, Any]:
|
||||||
|
event_id = kwargs.get("event_id")
|
||||||
|
user_id = kwargs.get("user_id")
|
||||||
|
if not event_id:
|
||||||
|
return {"success": False, "error": "Missing required field: event_id"}
|
||||||
|
if not user_id:
|
||||||
|
return {"success": False, "error": "Missing required field: user_id"}
|
||||||
|
|
||||||
|
# Build fields dict from updatable params (only those explicitly provided)
|
||||||
|
updatable = ["title", "description", "start_time", "end_time", "location", "is_all_day"]
|
||||||
|
fields: dict[str, Any] = {}
|
||||||
|
for key in updatable:
|
||||||
|
if key in kwargs and kwargs[key] is not None:
|
||||||
|
fields[key] = kwargs[key]
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
return {"success": False, "error": "No fields to update"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated = await self._service.update_event(event_id, fields)
|
||||||
|
if not updated:
|
||||||
|
return {"success": False, "error": f"Event not found: {event_id}"}
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"update_event failed: {e}"}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# delete_event
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _delete_event(self, **kwargs) -> dict[str, Any]:
|
||||||
|
event_id = kwargs.get("event_id")
|
||||||
|
user_id = kwargs.get("user_id")
|
||||||
|
if not event_id:
|
||||||
|
return {"success": False, "error": "Missing required field: event_id"}
|
||||||
|
if not user_id:
|
||||||
|
return {"success": False, "error": "Missing required field: user_id"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted = await self._service.delete_event(event_id)
|
||||||
|
if not deleted:
|
||||||
|
return {"success": False, "error": f"Event not found: {event_id}"}
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"delete_event failed: {e}"}
|
||||||
|
|
@ -0,0 +1,352 @@
|
||||||
|
"""Tests for CalendarTool — Agent tool wrapper for ReAct integration (U3)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentkit.calendar.db import get_event_tags, init_calendar_db
|
||||||
|
from agentkit.calendar.service import CalendarService
|
||||||
|
from agentkit.tools.calendar_tool import CalendarTool
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(tmp_path: Path) -> CalendarService:
|
||||||
|
"""Provide a CalendarService backed by a temp DB."""
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
asyncio.run(init_calendar_db(db_path))
|
||||||
|
return CalendarService(db_path=db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tool(service: CalendarService) -> CalendarTool:
|
||||||
|
return CalendarTool(calendar_service=service)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_event
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_action_returns_success(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""create_event returns success and persists the event with source='agent'."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Sprint Planning",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
description="Bi-weekly sprint planning",
|
||||||
|
location="Room A",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
event_dict = result["event"]
|
||||||
|
assert event_dict["title"] == "Sprint Planning"
|
||||||
|
assert event_dict["source"] == "agent"
|
||||||
|
assert event_dict["user_id"] == "user-1"
|
||||||
|
|
||||||
|
# Verify persisted in DB
|
||||||
|
fetched = await service.get_event(event_dict["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.title == "Sprint Planning"
|
||||||
|
assert fetched.source == "agent"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_with_recurrence_sets_rrule(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""rrule param is stored correctly on the event."""
|
||||||
|
rrule = "FREQ=WEEKLY;BYDAY=MO;COUNT=10"
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Weekly Standup",
|
||||||
|
start_time="2026-07-06T09:00:00+00:00",
|
||||||
|
end_time="2026-07-06T09:30:00+00:00",
|
||||||
|
rrule=rrule,
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["event"]["rrule"] == rrule
|
||||||
|
|
||||||
|
fetched = await service.get_event(result["event"]["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.rrule == rrule
|
||||||
|
|
||||||
|
|
||||||
|
async def test_query_events_returns_list(tool: CalendarTool) -> None:
|
||||||
|
"""create 2 events, query, verify both returned."""
|
||||||
|
await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Event A",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Event B",
|
||||||
|
start_time="2026-07-02T14:00:00+00:00",
|
||||||
|
end_time="2026-07-02T15:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await tool.execute(
|
||||||
|
action="query_events",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
events = result["events"]
|
||||||
|
assert len(events) == 2
|
||||||
|
titles = {e["title"] for e in events}
|
||||||
|
assert titles == {"Event A", "Event B"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_event_action_modifies_fields(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""create then update title; verify the field is modified."""
|
||||||
|
create_result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Original Title",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert create_result["success"] is True
|
||||||
|
event_id = create_result["event"]["id"]
|
||||||
|
|
||||||
|
update_result = await tool.execute(
|
||||||
|
action="update_event",
|
||||||
|
user_id="user-1",
|
||||||
|
event_id=event_id,
|
||||||
|
title="Updated Title",
|
||||||
|
)
|
||||||
|
assert update_result["success"] is True
|
||||||
|
|
||||||
|
fetched = await service.get_event(event_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.title == "Updated Title"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_event_action_removes_record(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""create then delete; verify the record is gone."""
|
||||||
|
create_result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="To Be Deleted",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert create_result["success"] is True
|
||||||
|
event_id = create_result["event"]["id"]
|
||||||
|
|
||||||
|
delete_result = await tool.execute(
|
||||||
|
action="delete_event",
|
||||||
|
user_id="user-1",
|
||||||
|
event_id=event_id,
|
||||||
|
)
|
||||||
|
assert delete_result["success"] is True
|
||||||
|
|
||||||
|
fetched = await service.get_event(event_id)
|
||||||
|
assert fetched is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# error paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_action_returns_error(tool: CalendarTool) -> None:
|
||||||
|
"""Unknown action returns success=False with error message."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="frobnicate",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "Unknown action" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_required_field_returns_error(tool: CalendarTool) -> None:
|
||||||
|
"""create_event without title returns success=False."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "Missing required field" in result["error"]
|
||||||
|
assert "title" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# conversation_id
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_created_event_has_conversation_id(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""conversation_id is set from context when provided."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Chat-Initiated Event",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
conversation_id="conv-abc-123",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["event"]["conversation_id"] == "conv-abc-123"
|
||||||
|
|
||||||
|
fetched = await service.get_event(result["event"]["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.conversation_id == "conv-abc-123"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# event_type_name resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_with_event_type_name(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""create event with event_type_name='Meeting' creates the type and links it."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Strategy Sync",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
event_type_name="Meeting",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
event_type_id = result["event"]["event_type_id"]
|
||||||
|
assert event_type_id is not None
|
||||||
|
|
||||||
|
# Verify the event type was created
|
||||||
|
types = await service.list_event_types("user-1")
|
||||||
|
meeting_types = [t for t in types if t.name == "Meeting"]
|
||||||
|
assert len(meeting_types) == 1
|
||||||
|
assert meeting_types[0].id == event_type_id
|
||||||
|
|
||||||
|
# Verify the event references the type
|
||||||
|
fetched = await service.get_event(result["event"]["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.event_type_id == event_type_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_reuses_existing_event_type(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""If event_type_name matches an existing type, it is reused (not duplicated)."""
|
||||||
|
# Pre-create the type
|
||||||
|
existing = await service.create_event_type("user-1", "Meeting")
|
||||||
|
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Second Meeting",
|
||||||
|
start_time="2026-07-02T10:00:00+00:00",
|
||||||
|
end_time="2026-07-02T11:00:00+00:00",
|
||||||
|
event_type_name="Meeting",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["event"]["event_type_id"] == existing.id
|
||||||
|
|
||||||
|
# No duplicate type created
|
||||||
|
types = await service.list_event_types("user-1")
|
||||||
|
meeting_types = [t for t in types if t.name == "Meeting"]
|
||||||
|
assert len(meeting_types) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# tag_names resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_with_tag_names(
|
||||||
|
tool: CalendarTool, service: CalendarService, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""create event with tag_names=['urgent', 'work'] creates tags and links them."""
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Urgent Work Task",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
tag_names=["urgent", "work"],
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
event_id = result["event"]["id"]
|
||||||
|
|
||||||
|
# Verify tags were created
|
||||||
|
tags = await service.list_tags("user-1")
|
||||||
|
tag_names = {t.name for t in tags}
|
||||||
|
assert "urgent" in tag_names
|
||||||
|
assert "work" in tag_names
|
||||||
|
|
||||||
|
# Verify tags are linked to the event
|
||||||
|
linked_tags = await get_event_tags(event_id, service.db_path)
|
||||||
|
linked_names = {t.name for t in linked_tags}
|
||||||
|
assert linked_names == {"urgent", "work"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_event_reuses_existing_tags(
|
||||||
|
tool: CalendarTool, service: CalendarService
|
||||||
|
) -> None:
|
||||||
|
"""If a tag name matches an existing tag, it is reused (not duplicated)."""
|
||||||
|
# Pre-create a tag
|
||||||
|
existing = await service.create_tag("user-1", "urgent")
|
||||||
|
|
||||||
|
result = await tool.execute(
|
||||||
|
action="create_event",
|
||||||
|
user_id="user-1",
|
||||||
|
title="Tagged Event",
|
||||||
|
start_time="2026-07-01T10:00:00+00:00",
|
||||||
|
end_time="2026-07-01T11:00:00+00:00",
|
||||||
|
tag_names=["urgent", "new-tag"],
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
# 'urgent' should not be duplicated, 'new-tag' should be created
|
||||||
|
tags = await service.list_tags("user-1")
|
||||||
|
urgent_tags = [t for t in tags if t.name == "urgent"]
|
||||||
|
assert len(urgent_tags) == 1
|
||||||
|
assert urgent_tags[0].id == existing.id
|
||||||
|
new_tags = [t for t in tags if t.name == "new-tag"]
|
||||||
|
assert len(new_tags) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# tool registration / schema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_name_and_schema(tool: CalendarTool) -> None:
|
||||||
|
"""Tool has correct name and input_schema."""
|
||||||
|
assert tool.name == "calendar"
|
||||||
|
schema = tool.input_schema
|
||||||
|
assert schema["type"] == "object"
|
||||||
|
assert "action" in schema["properties"]
|
||||||
|
assert "user_id" in schema["properties"]
|
||||||
|
assert schema["properties"]["action"]["enum"] == [
|
||||||
|
"create_event",
|
||||||
|
"query_events",
|
||||||
|
"update_event",
|
||||||
|
"delete_event",
|
||||||
|
]
|
||||||
|
assert "action" in schema["required"]
|
||||||
|
assert "user_id" in schema["required"]
|
||||||
Loading…
Reference in New Issue