feat(chat): web chat channel backend and client#65
Conversation
Server-side infrastructure for the Phantom chat channel. No client code yet - this PR stands up the backend that PR2's React client will connect to. New modules in src/chat/: - 24-event SSE wire format (types, sdk-to-wire translator) - Session, message, event log, and attachment SQLite stores - Per-session writer with stream bus fan-out and abort support - HTTP route handlers for /chat/* endpoints - Auto-rename via judgeQuery Haiku (Cardinal Rule) - Periodic sweep for expired sessions and orphan data Integration points: - AgentRuntime.runForChat using AsyncIterable<SDKUserMessage> with includePartialMessages, agentProgressSummaries, promptSuggestions - WebChatChannel registered for health/discovery (bypasses router) - Cookie path widened from /ui to / for cross-path auth - 12 SQLite migrations (indices 28-39) for chat tables 68 new tests, 1471 total, zero regressions.
CI fails to resolve @anthropic-ai/sdk/resources because it is a transitive dependency, not a direct one. Derive the type from SDKUserMessage["message"] which the agent SDK does export. Also fix implicit any types in the security wrapping code.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8019e0e18a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| `DELETE FROM chat_sessions | ||
| WHERE deleted_at IS NOT NULL | ||
| AND deleted_at < datetime('now', ?)`, |
There was a problem hiding this comment.
Delete dependent rows before hard-deleting chat sessions
hardDeleteExpired removes from chat_sessions directly, but this commit also adds chat_messages, chat_stream_events, and chat_attachments with foreign keys to chat_sessions (without cascade), so deleting an expired soft-deleted session that has activity will raise a foreign-key constraint error and the sweep cannot purge it. This is reproducible when a deleted session has at least one message/event; cleanup will keep failing until child rows are removed first (or FKs are made cascading).
Useful? React with 👍 / 👎.
| let seq = 0; | ||
| const unsubscribe = streamBus.subscribe(sessionId, (frame) => { | ||
| write(formatSSE(frame, ++seq)); |
There was a problem hiding this comment.
Keep SSE IDs aligned with persisted event sequence
/chat/stream starts SSE IDs from 1 on every request (let seq = 0), but /chat/sessions/:id/resume replays by persisted DB sequence via eventLog.drain(sessionId, client_last_seq). Once a session already has historical events, the client's last SSE ID no longer matches stored seq, so reconnect can replay stale events or skip fresh ones. Seed stream IDs from the session's current max persisted seq so emitted IDs remain resumable.
Useful? React with 👍 / 👎.
| "AND (pinned < ? OR (pinned = ? AND (COALESCE(last_message_at,'') < ? OR (COALESCE(last_message_at,'') = ? AND id < ?))))"; | ||
| params.push(parts[0], parts[0], parts[1], parts[1], parts[2]); |
There was a problem hiding this comment.
Use the same sort key in cursor pagination predicate
Pagination filters on COALESCE(last_message_at,''), but ordering is COALESCE(last_message_at, created_at) DESC. For sessions with last_message_at IS NULL (new chats), the cursor stores "" and page filtering falls back to UUID comparison, which is not creation-time ordered, causing dropped or duplicated sessions between pages. The cursor predicate/token should use COALESCE(last_message_at, created_at) to match the ORDER BY key.
Useful? React with 👍 / 👎.
The agent SDK imports MessageParam from @anthropic-ai/sdk/resources, a transitive dep that does not reliably hoist in CI. When the type degrades to any, callback parameters in .filter() and .map() become implicit any, failing strict typecheck. Extract security-wrapping helpers into message-param-utils.ts using imperative loops with explicit as-casts on loop variables, which produce the correct types regardless of whether the upstream import chain resolves.
P0: seq double-increment breaking reconnect - translator no longer
assigns seq, writer.emitFrame is the sole seq authority. StreamBus
now passes (frame, seq) tuples so SSE IDs match persisted event log.
P1 fixes:
- content_block_stop emits only the correct end frame per block type
(was emitting both text_end and thinking_end for every block)
- input_json_delta uses real tool_call_id from context mapping
(was fabricating pending_${index} that never matched)
- SSE subscription and keepAlive interval leak on client disconnect
(added cancel() callbacks to both ReadableStreams)
- wrapMessageContent wraps only the last text block, not all blocks
with the same combined string (accepts wrapFn instead of pre-wrapped)
- TOCTOU race on busy check: writer.claim() registers synchronously
before async run() starts
- hardDeleteExpired deletes child rows in a transaction before sessions
(was violating FK constraints)
- Cursor pagination uses COALESCE(last_message_at, created_at)
consistently in ORDER BY, cursor clause, and cursor encoding
P2 fixes:
- Resume handler checks writerActive inside start() callback
- Memory context uses extractTextFromMessageParam for array content
- Static file serving gated behind auth check
- updateSession validates status against allowed values
16 new tests covering the fixed paths. 1,485 total, 0 failures.
Summary
Full web chat channel for Phantom - an alternative entry point to Slack. Users without Slack configured can chat with Phantom via a browser-based UI with real-time streaming, tool activity cards, thinking blocks, session management, and push notifications.
This is a multi-commit PR built in four phases:
PR1: Backend Foundations (this commit)
AgentRuntime.runForChatusingAsyncIterable<SDKUserMessage>withincludePartialMessages,agentProgressSummaries,promptSuggestions/chat/*endpointsWebChatChannelregistered for health/discovery (bypasses router for hot path)judgeQueryHaiku call (Cardinal Rule)/uito/for cross-path authPR2: Client Scaffold and Streaming (upcoming)
chat-ui/at repo root: Vite + React + TypeScript + shadcn/ui + Tailwind v4chat-ui-builderstagePR3: Auth, First-Run, Web Push (upcoming)
PR4: Polish and Hardening (upcoming)
Architecture
chat_messagesfor UI display/replay. Never unified.MessageParamcontent blocks everywhere. No Phantom-invented shapes.runForChatdirectly, does not go throughChannelRouter.onMessage.Test plan
bun testpasses (1,471 tests, 0 failures)bun run typecheckpassesbun run lintpasses/chat/sessions,/chat/stream,/chat/sessions/:id/resume,/chat/sessions/:id/abort