Skip to content

feat(chat): web chat channel backend and client#65

Merged
mcheemaa merged 4 commits into
mainfrom
project-4-chat-channel
Apr 15, 2026
Merged

feat(chat): web chat channel backend and client#65
mcheemaa merged 4 commits into
mainfrom
project-4-chat-channel

Conversation

@mcheemaa
Copy link
Copy Markdown
Member

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)

  • 24-event SSE wire format with SDK-to-wire translator
  • Chat session, message, event log, and attachment SQLite stores (migrations 28-39)
  • AgentRuntime.runForChat using AsyncIterable<SDKUserMessage> with includePartialMessages, agentProgressSummaries, promptSuggestions
  • Per-session writer with stream bus fan-out and abort support
  • HTTP route handlers for all /chat/* endpoints
  • WebChatChannel registered for health/discovery (bypasses router for hot path)
  • Auto-rename via judgeQuery Haiku call (Cardinal Rule)
  • Cookie path widened from /ui to / for cross-path auth
  • 68 new tests, 1,471 total, zero regressions

PR2: Client Scaffold and Streaming (upcoming)

  • chat-ui/ at repo root: Vite + React + TypeScript + shadcn/ui + Tailwind v4
  • Full streaming message list, composer, tool cards, thinking blocks, auto-scroll
  • Session sidebar with date grouping, rename, delete
  • Dockerfile chat-ui-builder stage

PR3: Auth, First-Run, Web Push (upcoming)

  • Email first-run for Slack-less deployments
  • Service Worker, Push API, iOS PWA install
  • Four notification triggers with focus gate and debounce

PR4: Polish and Hardening (upcoming)

  • Command palette, keyboard shortcuts, file attachments
  • Accessibility, Playwright E2E test

Architecture

  • Two-transcript invariant: SDK session file for SDK reads, chat_messages for UI display/replay. Never unified.
  • SDK-native wire format: MessageParam content blocks everywhere. No Phantom-invented shapes.
  • Channel bypass: Chat invokes runForChat directly, does not go through ChannelRouter.onMessage.
  • Writer lifetime decoupled from connections: Multiple tabs can subscribe. Disconnects never stop generation.

Test plan

  • bun test passes (1,471 tests, 0 failures)
  • bun run typecheck passes
  • bun run lint passes
  • Manual curl testing of /chat/sessions, /chat/stream, /chat/sessions/:id/resume, /chat/sessions/:id/abort
  • PR2-PR4 commits land on this branch before final merge

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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/chat/session-store.ts Outdated
Comment on lines +182 to +184
`DELETE FROM chat_sessions
WHERE deleted_at IS NOT NULL
AND deleted_at < datetime('now', ?)`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/chat/http-handlers.ts Outdated
Comment on lines +195 to +197
let seq = 0;
const unsubscribe = streamBus.subscribe(sessionId, (frame) => {
write(formatSSE(frame, ++seq));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/chat/session-store.ts Outdated
Comment on lines +75 to +76
"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]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
@mcheemaa mcheemaa merged commit 1079518 into main Apr 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant