fischer-agentkit/docs/solutions/ui-bugs/calendar-agent-create-no-re...

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
Calendar event created via agent chat is persisted to DB but does not appear in the calendar UI tab
Calendar tab must be manually reloaded to see newly created events
No calendar_event_created WebSocket message is emitted on agent-driven creation
CalendarPanel.vue only calls store.loadEvents() in onMounted; tab switches do not re-mount the component
missing_workflow_step code_fix medium
calendar
websocket
ui-refresh
agent-driven
pinia
broadcast
notify-callback

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 over stores/calendar.ts disproved this: handleWsEvent already has a calendar_event_created branch at line 209 that pushes the event into the reactive events array. 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 of stores/chat.ts:1860-1868 showed it already forwards calendar_* events (including calendar_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:205 only calls store.loadEvents() in onMounted, and CalendarTab lives inside QuadrantPanel's slot in AgentLayout.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 store handleWsEvent (calendar.ts:209) → push into the events array → 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.
  • CalendarService stays decoupled from the portal module. The service takes a notify_callback rather than importing portal directly, preserving layering. app.py is the composition root that wires the closure.
  • Shared broadcast channel. Reusing the existing _calendar_ws_sender closure means CalendarService and ReminderScheduler fan out through the same portal.send_to_user path. Mirrors the pattern already used by sync providers (which broadcast calendar_sync_conflict via the same notify_callback injection 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.pyCalendarService.create_event) and the REST route path (server/routes/calendar.pyCalendarService.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_callback injection pattern for state-mutating services. Any service whose mutations the frontend renders should accept an optional broadcast callback wired at the composition root. CalendarService now has it for create_event; the same shape should be extended to update_event and delete_event if 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 pass notify_callback.
  • Separate latent fragility — out of scope. CalendarPanel.vue only calls store.loadEvents() in onMounted; 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.py clean.
  • Manual end-to-end verification in the GUI pending (user will test).

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:

  1. calendar-capability-and-ui-fixes.mdCalendarTool built reminder_rules but dropped them in create_event, and the default agent was created before CalendarTool registration so the LLM never saw the tool.
  2. jwt-secret-dev-mode-user-id-mismatch.md — JWT secret unset in dev mode → user_id=None in queries vs LLM-hallucinated user_id in DB rows.
  3. This docCalendarService.create_event had no notify_callback and never broadcast calendar_event_created.