132 lines
9.7 KiB
Markdown
132 lines
9.7 KiB
Markdown
---
|
|
title: "Calendar events created via agent chat do not refresh the calendar UI"
|
|
date: 2026-06-29
|
|
category: docs/solutions/ui-bugs/
|
|
module: calendar
|
|
problem_type: ui_bug
|
|
component: service_object
|
|
symptoms:
|
|
- "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"
|
|
root_cause: missing_workflow_step
|
|
resolution_type: code_fix
|
|
severity: medium
|
|
tags: [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`:**
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
from collections.abc import Awaitable, Callable
|
|
```
|
|
|
|
**Edit 2 — `src/agentkit/calendar/service.py:183-199` — best-effort broadcast before returning from `create_event`:**
|
|
|
|
```python
|
|
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:**
|
|
|
|
```python
|
|
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.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_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).
|
|
|
|
## 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:
|
|
|
|
1. [calendar-capability-and-ui-fixes.md](../logic-errors/calendar-capability-and-ui-fixes.md) — `CalendarTool` 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](../integration-issues/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 doc** — `CalendarService.create_event` had no `notify_callback` and never broadcast `calendar_event_created`.
|