9.7 KiB
| title | date | category | module | problem_type | component | symptoms | root_cause | resolution_type | severity | tags | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Calendar events created via agent chat do not refresh the calendar UI | 2026-06-29 | docs/solutions/ui-bugs/ | calendar | ui_bug | service_object |
|
missing_workflow_step | code_fix | medium |
|
Calendar events created via agent chat do not refresh the calendar UI
Problem
A user creates a calendar reminder through chat with the agent; the event is persisted to the SQLite DB successfully, but opening the calendar tab in the UI does not show the newly created event. The calendar view never learns about agent-driven creations until the user manually refreshes the page.
Symptoms
- Agent chat confirms event creation (and the row is present in the calendar DB), but the calendar tab still shows the previous event list.
- Switching to the calendar tab immediately after creation shows nothing new.
- A full page refresh is required to see the event — proving the data is correct, only the live UI update is missing.
What Didn't Work
- Suspected a missing frontend handler. Initial instinct was that the calendar store had no case for
calendar_event_created. A grep overstores/calendar.tsdisproved this:handleWsEventalready has acalendar_event_createdbranch at line 209 that pushes the event into the reactiveeventsarray. The handler was never the problem. - Suspected the chat store didn't dispatch calendar events. The next hypothesis was that the chat WS router swallowed
calendar_*messages. Inspection ofstores/chat.ts:1860-1868showed it already forwardscalendar_*events (includingcalendar_event_created) to_getCalendarStore().handleWsEvent(data). The dispatch wiring exists end-to-end. - Suspected the calendar panel didn't reload on tab switch. This is a real latent issue (
CalendarPanel.vue:205only callsstore.loadEvents()inonMounted, and CalendarTab lives inside QuadrantPanel's slot inAgentLayout.vue:66-84, so tab switching does not re-mount the component) — but it is not the root cause of the agent-creation miss. Even a freshly mounted panel would still rely on a WS push for live updates; the actual gap was upstream.
In every case the frontend side of the chain was complete. The gap was backend-only: CalendarService.create_event never emitted the WS message in the first place.
Solution
Three minimal edits, all threading an existing WebSocket fan-out closure into CalendarService so create_event can broadcast calendar_event_created to the user's open chat tabs.
Edit 1 — src/agentkit/calendar/service.py:84-95 — accept an optional notify_callback:
def __init__(
self,
db_path: str | Path | None = None,
auth_db_path: str | Path | None = None,
notify_callback: Callable[[str, dict[str, object]], Awaitable[None]] | None = None,
) -> None:
self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
self.auth_db_path = Path(auth_db_path) if auth_db_path is not None else DEFAULT_AUTH_DB_PATH
# Optional WS broadcast callback — wired by app.py so create_event
# can push `calendar_event_created` to the user's open chat tabs
# without the service depending on the portal module.
self._notify = notify_callback
The corresponding import was added at the top of the file:
from collections.abc import Awaitable, Callable
Edit 2 — src/agentkit/calendar/service.py:183-199 — best-effort broadcast before returning from create_event:
logger.info(f"Created event {event.id} ({title}) for user {user_id}")
# Broadcast to the user's open chat tabs so the calendar view
# refreshes in real time without a manual reload. Best-effort:
# WS delivery failure must not roll back the successful insert.
if self._notify is not None:
try:
await self._notify(
user_id,
{"type": "calendar_event_created", "data": {"event": event.to_dict()}},
)
except Exception:
logger.warning(
"calendar_event_created broadcast failed for event %s",
event.id,
exc_info=True,
)
return event
Edit 3 — src/agentkit/server/app.py:435-448 — reorder the closure definition before CalendarService instantiation and inject it:
await init_calendar_db()
# Wire portal WebSocket fan-out so calendar events reach the user's
# open chat tab(s) in real time. Shared by CalendarService (for
# create_event broadcasts) and ReminderScheduler (for reminders).
async def _calendar_ws_sender(user_id: str, message: dict[str, object]) -> None:
await portal.send_to_user(user_id, message)
cal_service = CalendarService(notify_callback=_calendar_ws_sender)
app.state.calendar_service = cal_service
calendar_scheduler = ReminderScheduler(
dispatcher=ReminderDispatcher(ws_sender=_calendar_ws_sender)
)
The _calendar_ws_sender closure and portal.send_to_user already existed — only the ordering changed (the closure must be defined before CalendarService is constructed) and the callback was threaded into the service.
Why This Works
- The frontend chain was already complete. WS arrival → chat store dispatch (
chat.ts:1866) → calendar storehandleWsEvent(calendar.ts:209) → push into theeventsarray → reactive UI update. The only missing link was the backend emit, which Edit 2 adds. Once the message reaches the portal, the rest fires automatically. CalendarServicestays decoupled from the portal module. The service takes anotify_callbackrather than importingportaldirectly, preserving layering.app.pyis the composition root that wires the closure.- Shared broadcast channel. Reusing the existing
_calendar_ws_senderclosure meansCalendarServiceandReminderSchedulerfan out through the sameportal.send_to_userpath. Mirrors the pattern already used by sync providers (which broadcastcalendar_sync_conflictvia the samenotify_callbackinjection shape). - Best-effort broadcast preserves the audit trail. WS delivery failure (user offline, no open tab, transient portal error) is logged at warning level but does not roll back the successful DB insert. The event is durable; the user just doesn't get a live refresh for that single creation, which is acceptable — the next manual
loadEvents()will show it. - Covers both creation paths. Both the agent tool path (
tools/calendar_tool.py→CalendarService.create_event) and the REST route path (server/routes/calendar.py→CalendarService.create_event) are thin wrappers over the service, so both now broadcast.
Prevention
- Trace WS event chains end-to-end. When adding a new WS event type the frontend should react to, trace the full chain: backend emit → portal broadcast → chat store dispatch → domain store handler → UI. A handler on the frontend with no matching emit on the backend is a silent dead path — exactly this bug. The reverse (an emit with no handler) at least logs an unhandled-event warning.
- Default to the
notify_callbackinjection pattern for state-mutating services. Any service whose mutations the frontend renders should accept an optional broadcast callback wired at the composition root.CalendarServicenow has it forcreate_event; the same shape should be extended toupdate_eventanddelete_eventif live multi-tab consistency becomes a requirement. - Regression tests guard the fix:
tests/unit/calendar/test_service.py::test_create_event_broadcasts_via_notify_callback— verifies the callback fires exactly once with the correct payload shape ({"type": "calendar_event_created", "data": {"event": ...}}).tests/unit/calendar/test_service.py::test_create_event_without_callback_does_not_raise— verifies the default (no callback) path still works for existing callers that don't passnotify_callback.
- Separate latent fragility — out of scope.
CalendarPanel.vueonly callsstore.loadEvents()inonMounted; switching tabs does not re-mount the component (it lives in QuadrantPanel's slot), so the panel never reloads after initial mount. The WS broadcast fix papers over this for the agent-creation case, but a future hardening would be to also reload on tab activation. Tracked separately; not part of this fix.
Verification
- 132 existing calendar unit tests pass (no regressions).
- 2 new regression tests pass.
ruff check src/agentkit/calendar/service.py src/agentkit/server/app.pyclean.- Manual end-to-end verification in the GUI pending (user will test).
Related Issues
This is the third distinct root cause documented for the same symptom ("agent creates calendar event → UI does not show it"). When investigating this symptom, check all three:
- calendar-capability-and-ui-fixes.md —
CalendarToolbuiltreminder_rulesbut dropped them increate_event, and the default agent was created beforeCalendarToolregistration so the LLM never saw the tool. - jwt-secret-dev-mode-user-id-mismatch.md — JWT secret unset in dev mode →
user_id=Nonein queries vs LLM-hallucinateduser_idin DB rows. - This doc —
CalendarService.create_eventhad nonotify_callbackand never broadcastcalendar_event_created.