diff --git a/src/tool_implementations.py b/src/tool_implementations.py index 494795037..27c05f139 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -1453,6 +1453,42 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict: except ValueError: return {"error": "Invalid JSON arguments", "exit_code": 1} + # ── Batch normalization ── + # Some models (e.g. deepseek-v4-flash) emit {"events": [{...}, ...]} + # instead of individual create_event calls. Iterate and create each. + if isinstance(args.get("events"), list) and not args.get("action"): + results = [] + for ev in args["events"]: + if not isinstance(ev, dict): + continue + # Normalize start/end from {dateTime: "..."} object to flat string + for field, target in [("start", "dtstart"), ("end", "dtend")]: + val = ev.pop(field, None) + if val and target not in ev: + ev[target] = val.get("dateTime", val) if isinstance(val, dict) else val + ev.setdefault("action", "create_event") + r = await do_manage_calendar(json.dumps(ev), owner=owner) + results.append(r) + created = [r for r in results if r.get("exit_code") == 0 and not r.get("error")] + failed = [r for r in results if r.get("error")] + + if not results: + return {"error": "No events to create", "exit_code": 1} + + # Surface both successes and failures + parts = [] + if created: + summaries = [r.get("response", "") for r in created] + parts.append(f"Created {len(created)} event(s):\n" + "\n".join(summaries)) + if failed: + first_error = failed[0].get("error", "Unknown error") + parts.append(f"Failed to create {len(failed)} event(s). First error: {first_error}") + + response = "\n\n".join(parts) + # Non-zero exit code for partial or total failure + exit_code = 0 if not failed else 1 + return {"response": response, "exit_code": exit_code, "created_count": len(created), "failed_count": len(failed)} + # Normalize action — some models emit hyphens ("list-calendars") instead # of underscores. Treat them as equivalent so we don't bounce a # cosmetic typo back to the model and waste a round-trip. Also accept diff --git a/tests/test_calendar_batch_events.py b/tests/test_calendar_batch_events.py new file mode 100644 index 000000000..d8176afcd --- /dev/null +++ b/tests/test_calendar_batch_events.py @@ -0,0 +1,125 @@ +"""Test that do_manage_calendar handles the batch {"events": [...]} format +that models like deepseek-v4-flash emit instead of individual create_event calls. +""" + +import json +import sys +import uuid + +import pytest + +from tests.helpers.import_state import clear_fake_database_modules +from tests.helpers.sqlite_db import make_temp_sqlite + +clear_fake_database_modules() + +import core.database as cdb +from core.database import CalendarEvent + +_TS, _ENGINE, _TMPDB = make_temp_sqlite(cdb.Base.metadata) + + +@pytest.fixture(autouse=True) +def _bind_temp_db(monkeypatch): + monkeypatch.setitem(sys.modules, "core.database", cdb) + parent = sys.modules.get("core") + if parent is not None: + monkeypatch.setattr(parent, "database", cdb, raising=False) + monkeypatch.setattr(cdb, "SessionLocal", _TS) + yield + + +async def test_batch_events_with_datetime_objects(): + """Model emits {"events": [{"summary": ..., "start": {"dateTime": ...}, "end": {"dateTime": ...}}]}.""" + from src.tool_implementations import do_manage_calendar + + owner = "tester-" + uuid.uuid4().hex[:6] + payload = { + "events": [ + { + "summary": "Morning Gym", + "start": {"dateTime": "2026-06-09T06:00:00+05:30"}, + "end": {"dateTime": "2026-06-09T07:00:00+05:30"}, + }, + { + "summary": "Morning Gym", + "start": {"dateTime": "2026-06-10T06:00:00+05:30"}, + "end": {"dateTime": "2026-06-10T07:00:00+05:30"}, + }, + ] + } + res = await do_manage_calendar(json.dumps(payload), owner=owner) + assert res.get("exit_code") == 0, res + assert "Created 2 event(s)" in res.get("response", "") + + # Verify events exist in DB + db = _TS() + events = db.query(CalendarEvent).filter(CalendarEvent.summary == "Morning Gym").all() + assert len(events) == 2 + db.close() + + +async def test_batch_events_with_flat_strings(): + """Model emits {"events": [{"summary": ..., "start": "ISO", "end": "ISO"}]}.""" + from src.tool_implementations import do_manage_calendar + + owner = "tester-" + uuid.uuid4().hex[:6] + payload = { + "events": [ + { + "summary": "Standup", + "start": "2026-06-09T09:00:00", + "end": "2026-06-09T09:30:00", + }, + ] + } + res = await do_manage_calendar(json.dumps(payload), owner=owner) + assert res.get("exit_code") == 0, res + assert "Created 1 event(s)" in res.get("response", "") + + +async def test_batch_events_partial_failure(): + """Batch with some valid and some invalid events — should surface both counts and first error.""" + from src.tool_implementations import do_manage_calendar + + owner = "tester-" + uuid.uuid4().hex[:6] + payload = { + "events": [ + { + "summary": "Valid Event 1", + "start": "2026-06-09T10:00:00", + "end": "2026-06-09T11:00:00", + }, + { + "summary": "Invalid Event", + # Missing required dtstart — will fail + }, + { + "summary": "Valid Event 2", + "start": "2026-06-09T14:00:00", + "end": "2026-06-09T15:00:00", + }, + ] + } + res = await do_manage_calendar(json.dumps(payload), owner=owner) + + # Partial failure = non-zero exit code + assert res.get("exit_code") != 0, "Partial failure should return non-zero exit code" + + # Response should mention both created and failed counts + response = res.get("response", "") + assert "Created 2 event(s)" in response, f"Should report 2 created: {response}" + assert "Failed to create 1 event(s)" in response, f"Should report 1 failed: {response}" + assert "error" in response.lower() or "required" in response.lower(), "Should include error details" + + # Metadata fields + assert res.get("created_count") == 2 + assert res.get("failed_count") == 1 + + # Verify only valid events were created + db = _TS() + events = db.query(CalendarEvent).filter( + CalendarEvent.summary.in_(["Valid Event 1", "Valid Event 2"]) + ).all() + assert len(events) == 2 + db.close()