mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
fix: handle batch events format in manage_calendar tool (#3503)
* fix: handle batch events format in manage_calendar tool Models like deepseek-v4-flash emit batch events array instead of individual create_event calls. The tool defaulted to list_events (no action key), so events were never created despite the model confirming success. - Add batch normalization in do_manage_calendar - Map start/end objects to flat dtstart/dtend strings - Add tests for both object and flat string formats * fix: surface partial batch failures in manage_calendar Partial failures were silently dropped - batches with mixed success/failure would report only created count with no error visibility. - Return non-zero exit code for any failures - Surface both created and failed counts in response - Include first error message for debugging - Add test for partial failure case * chore: strip trailing whitespace in batch normalization block * chore: strip whitespace-only blank lines in batch events test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user