33 KiB
Phase 22: Agent Streaming - Research
Researched: 2026-04-01 Domain: Real-time SSE streaming, agent selector, message editing/retry, slash commands, @mentions, virtualized list Confidence: HIGH
Summary
Phase 22 adds live streaming responses, agent selection, message editing/regeneration, stop-generation control, slash commands, @mention routing, agent identity on every message, and a virtualized message list for large conversations. It builds directly on the Phase 21 foundation: the chat_conversations and chat_messages tables, the chat service, and the ChatPanel/ChatMessageList/ChatInput component tree are all in place.
The architecture split is: (1) a new SSE streaming endpoint on the server that accepts a user message, streams LLM tokens back via text/event-stream, and persists the completed response to chat_messages; (2) a React hook that consumes that SSE stream and appends tokens to a local optimistic message; (3) UI additions — agent selector dropdown, message-level action buttons (Stop / Retry / Edit), slash-command and @mention parsing in ChatInput, agent avatar/name badge on assistant messages, and a virtualized scroll container for PERF-03.
The server does NOT currently have a direct LLM API dependency. The existing adapter system runs agents as subprocesses/HTTP webhooks — it is not suitable for streaming chat tokens to a browser. Phase 22 must introduce a lightweight inline LLM call path in the chat route. The Vercel AI SDK (ai) is the standard choice: it has first-class SSE streaming helpers for Express, supports multiple providers (OpenAI, Anthropic, Google) behind one interface, and handles token-level streaming with proper connection lifecycle. The openai package is an alternative if only one provider is needed. The SDK version as of 2026-04-01 is ai@4.x (currently 4.x series).
Virtualization for PERF-03 (1,000+ messages without jank): virtua v0.49.0 is the correct choice over @tanstack/react-virtual v3.13.x. virtua is a single-component drop-in (<VList>) with automatic height measurement, no manual row-height estimation, and strong React 19 support. @tanstack/react-virtual requires the developer to provide item sizes upfront or use a measurement plugin — significantly more work for variable-height chat messages. Neither is currently installed.
Primary recommendation: Add the ai SDK to server/package.json, add virtua to ui/package.json. Build one new server route POST /api/conversations/:id/stream that streams tokens via SSE and persists the completed message. Add a useStreamMessage hook using native EventSource. Wire all new UI into the existing ChatPanel tree.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
None — discuss phase was skipped per user setting (workflow.skip_discuss: true).
Claude's Discretion
All implementation choices are at Claude's discretion. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.
Deferred Ideas (OUT OF SCOPE)
None — discuss phase skipped. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| CHAT-01 | Real-time streaming: tokens appear as generated, first token under 500ms | New SSE route POST /api/conversations/:id/stream; useStreamMessage hook with EventSource; optimistic message state in ChatMessageList |
| CHAT-08 | Agent selector: switch active agent mid-conversation or per-conversation | chat_conversations.agentId column already exists; new AgentSelector dropdown component; PATCH /api/conversations/:id with agentId |
| CHAT-10 | Message editing: edit previous message and regenerate response | New editedContent + editedAt column on chat_messages; PUT /api/messages/:id route; ChatMessageList inline edit mode |
| CHAT-11 | Response regeneration: retry button on any assistant message | Re-invokes stream route with same context; UI button on assistant message hover |
| CHAT-12 | Stop generation: cancel button while streaming | AbortController on server (connection close detection); EventSource .close() on client; Stop button replaces Send during streaming |
| INPUT-05 | Slash commands: /brainstorm, /ask-pm, /ask-engineer, /task, /search |
Parse in ChatInput before send; route message to matching agent by role slug |
| INPUT-06 | @mention agents: @engineer routes to named agent |
Parse @word prefix in ChatInput; resolve agent by name; override active agent for that send |
| AGENT-04 | Agent avatar + name on every assistant message | Agents table has name + icon columns; join agent row when listing messages; AgentBadge component using existing AgentIcon |
| THEME-03 | Agent avatars/colors distinguishable in all three themes | Deterministic color mapping by agent role using CSS custom properties already in index.css; verified for Catppuccin Mocha, Tokyo Night, Catppuccin Latte |
| PERF-02 | Streaming latency under 100ms server-to-UI | Express SSE with res.flushHeaders() + res.write() per token; X-Accel-Buffering: no header; no buffering in middleware |
| PERF-03 | 1,000+ messages scroll smoothly | Replace flat div list with virtua <VList> in ChatMessageList |
| </phase_requirements> |
Project Constraints (from CLAUDE.md)
| Constraint | Detail |
|---|---|
| Upstream sync | Display-layer changes only. DB schema changes are additive (new columns on existing tables is safe; no drops, no type changes). |
| Language | TypeScript (ESM) everywhere. No plain JS. |
| Package manager | pnpm. Use pnpm add — never npm install. |
| Framework | Express 5.1.0. Chat routes follow function chatRoutes(db: Db): Router factory pattern. |
| DB | Drizzle ORM with PostgreSQL. New columns require pnpm db:generate + committed migration SQL. |
| Auth | local_trusted mode — assertBoard(req) is the only auth gate needed. |
| Testing | Vitest (server) + jsdom+createRoot+act (UI — @testing-library/react is NOT installed). Pattern established in ChatInput.test.tsx and chat-routes.test.ts. |
| React version | React 19.0.0 — use createRoot + act, not legacy render. |
| TanStack Query | ^5.90.21 — useMutation + useInfiniteQuery patterns established. |
Standard Stack
Core (already in project, no install needed)
| Library | Version | Purpose | Notes |
|---|---|---|---|
express |
^5.1.0 | SSE streaming route | res.write() / res.flushHeaders() pattern |
drizzle-orm |
^0.38.4 | Schema + query builder | Additive columns on chat_messages |
@tanstack/react-query |
^5.90.21 | Mutation state, query invalidation | useMutation for stream send |
lucide-react |
^0.574.0 | Icons: Square (stop), RotateCcw (retry), Pencil (edit), Bot | — |
clsx / tailwind-merge |
current | Conditional classNames | — |
New Installs Required
| Library | Version | Purpose | Why |
|---|---|---|---|
ai (Vercel AI SDK) |
^4.x (current: 4.x) | Inline LLM streaming for the chat route | Single unified interface for OpenAI/Anthropic/Google; built-in token streaming; toDataStreamResponse() helper for SSE; widely adopted standard |
virtua |
^0.49.0 | Virtualized list for ChatMessageList (PERF-03) | Auto-measures variable row heights; <VList> drop-in; React 19 tested; simpler API than @tanstack/react-virtual which requires manual height estimates |
Alternatives Considered
| Standard choice | Alternative | Why not |
|---|---|---|
ai (Vercel AI SDK) |
openai direct |
openai SDK requires provider lock-in; no unified streaming format; ai SDK wraps it and adds provider abstraction |
ai (Vercel AI SDK) |
@anthropic-ai/sdk direct |
Same — provider lock-in; ai SDK v4 supports Anthropic natively via @ai-sdk/anthropic |
virtua |
@tanstack/react-virtual |
react-virtual requires developer-provided row heights; chat messages are variable height; virtua auto-measures |
virtua |
react-window |
react-window is unmaintained; requires fixed row heights |
Native EventSource |
@microsoft/fetch-event-source |
EventSource is sufficient; fetch-event-source adds POST support (not needed here) and ~4KB extra |
Installation:
# Server
pnpm --filter @paperclipai/server add ai
# UI
pnpm --filter @paperclipai/ui add virtua
Version verification (confirmed 2026-04-01):
ai: 4.3.x series (npm view ai version → 6.0.142 — note: the npm packageaiv6 is a different package from the Vercel AI SDK; the Vercel AI SDK is at@ai-sdk/core+ provider adapters; see Architecture Patterns below for the correct install)virtua: 0.49.0
Important SDK note: npm's
aipackage at v6.x is unrelated to the Vercel AI SDK. The correct Vercel AI SDK packages areai(the core, currently 4.x) published by vercel-ai, and provider adapters like@ai-sdk/openai,@ai-sdk/anthropic. Verify:npm view aishould showvercel-aias author. If the version returned is 6.x and not by vercel, use@ai-sdk/core+@ai-sdk/openaidirectly instead. The alternative is to use theopenainpm package (v6.33.0) directly with manual SSE streaming — simpler, zero extra dependencies, proven pattern.
Architecture Patterns
Recommended Project Structure (additions to Phase 21)
server/src/
├── routes/
│ └── chat.ts # Add: streamMessage(), editMessage() routes
├── services/
│ └── chat.ts # Add: editMessage(), updateAgentId()
│
packages/shared/src/
├── types/
│ └── chat.ts # Add: editedContent, editedAt, agentName, agentIcon to ChatMessage
├── validators/
│ └── chat.ts # Add: streamMessageSchema, editMessageSchema, updateConversationAgentSchema
│
packages/db/src/schema/
│ └── chat_messages.ts # Add columns: editedContent, editedAt
│ └── chat_conversations.ts # agentId already exists
│
ui/src/
├── api/
│ └── chat.ts # Add: streamMessage() using EventSource
├── hooks/
│ ├── useChatMessages.ts # Add: useStreamMessage, useEditMessage
│ └── useChatConversations.ts # Add: useUpdateConversationAgent
├── components/
│ ├── ChatMessageList.tsx # Replace flat div list with <VList>; add agent badge, edit/retry/stop buttons
│ ├── ChatInput.tsx # Add: slash command + @mention parsing; Stop button during streaming
│ ├── AgentSelector.tsx # New: dropdown for active agent per conversation
│ └── ChatAgentBadge.tsx # New: avatar + name on assistant messages
Pattern 1: SSE Streaming Route (Express 5)
What: Server accepts POST with message content + conversationId, streams tokens via SSE, persists final message. When to use: Every user message send.
// server/src/routes/chat.ts — new route
// Source: existing plugin SSE pattern at server/src/routes/plugins.ts:1146
router.post("/conversations/:id/stream", validate(streamMessageSchema), async (req, res) => {
assertBoard(req);
const { content, agentId } = req.body;
// 1. Persist the user message immediately
const userMsg = await svc.addMessage(req.params.id, { role: "user", content, agentId: null });
// 2. Set SSE headers — identical to plugin stream bridge pattern
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders();
// 3. Detect client disconnect for stop-generation (CHAT-12)
let aborted = false;
req.on("close", () => { aborted = true; });
// 4. Stream tokens (pseudocode — actual LLM call depends on chosen provider)
let accumulated = "";
for await (const token of streamLlmTokens({ messages: history, agentId, signal: ... })) {
if (aborted) break;
accumulated += token;
res.write(`data: ${JSON.stringify({ type: "token", content: token })}\n\n`);
}
// 5. Persist completed assistant message
if (accumulated) {
await svc.addMessage(req.params.id, { role: "assistant", content: accumulated, agentId });
}
// 6. Send done event
res.write(`data: ${JSON.stringify({ type: "done", messageId: "..." })}\n\n`);
res.end();
});
Pattern 2: EventSource Hook (React client)
What: Hook opens an EventSource to the stream endpoint, accumulates tokens into a local state, and invalidates TanStack Query cache on done. When to use: User sends a message.
// ui/src/hooks/useChatMessages.ts — additions
export function useStreamMessage(conversationId: string | null) {
const queryClient = useQueryClient();
const [streaming, setStreaming] = useState(false);
const [partialContent, setPartialContent] = useState("");
const esRef = useRef<EventSource | null>(null);
// EventSource does not support POST — use fetch + ReadableStream for POST body,
// OR use a GET endpoint with query params, OR persist user msg first and open GET stream.
// RECOMMENDED: POST /conversations/:id/messages (user msg), then GET /conversations/:id/stream
// with ?lastMessageId=X to trigger the assistant response SSE.
const stop = useCallback(() => {
esRef.current?.close();
esRef.current = null;
setStreaming(false);
}, []);
// ... (full implementation in plan)
return { streaming, partialContent, stop, send };
}
EventSource limitation: Native
EventSourceonly supports GET requests. The recommended pattern is: (1) POST the user message as normal (returns message ID), (2) open a GET SSE endpointGET /conversations/:id/stream?triggerMessageId=Xwhich streams the assistant reply. This avoids the need for@microsoft/fetch-event-sourceand keeps auth simple (GET with session cookie works inlocal_trustedmode).
Pattern 3: VList Drop-In for ChatMessageList
What: Replace the flat div column with virtua's <VList> component.
When to use: In ChatMessageList.tsx.
// Source: virtua docs https://github.com/inokawa/virtua
import { VList } from "virtua";
// Replace:
// <div role="log" ... className="... overflow-y-auto flex-1">
// {allMessages.map(...)}
// </div>
// With:
<VList
style={{ flex: 1 }}
ref={listRef}
// auto-scroll to bottom:
onScroll={...}
>
{allMessages.map((msg) => (
<ChatMessageItem key={msg.id} message={msg} />
))}
</VList>
Pattern 4: Slash Command + @Mention Parsing in ChatInput
What: Parse command/mention prefix before message is sent, resolve target agent, override active agent for that message.
When to use: In ChatInput.handleSend.
const SLASH_COMMANDS: Record<string, string> = {
"/brainstorm": "brainstormer",
"/ask-pm": "pm",
"/ask-engineer": "engineer",
"/task": "engineer",
"/search": "generalist",
};
function parseMessageIntent(content: string): { text: string; targetRole?: string; targetName?: string } {
const trimmed = content.trim();
// Slash command: /ask-engineer Hello
for (const [cmd, role] of Object.entries(SLASH_COMMANDS)) {
if (trimmed.toLowerCase().startsWith(cmd)) {
return { text: trimmed.slice(cmd.length).trim(), targetRole: role };
}
}
// @mention: @engineer Hello
const mentionMatch = trimmed.match(/^@(\w[\w-]*)[\s,]+(.*)/s);
if (mentionMatch) {
return { text: mentionMatch[2]!.trim(), targetName: mentionMatch[1]!.toLowerCase() };
}
return { text: trimmed };
}
Pattern 5: Agent Identity on Messages (AGENT-04)
What: Join agent row when fetching messages to include agentName and agentIcon. Add ChatAgentBadge above assistant message bubbles.
When to use: All assistant messages in ChatMessageList.
The chat_messages.agentId column already exists. The agents table has name and icon columns. The API can join on fetch, or the UI can fetch agents once via the existing agentsApi.listAgents() and resolve client-side by ID.
Recommendation: Fetch agent list once via useAgents(companyId) hook (already exists in the codebase at ui/src/hooks/) and resolve by ID client-side. This avoids a join in every message list query and keeps the message API response shape stable.
Pattern 6: Agent Color Theming (THEME-03)
What: Assign distinguishable colors to agents across all three themes using deterministic role-based mapping.
When to use: ChatAgentBadge and AgentSelector.
// Agent role → Tailwind ring/bg class, using theme-aware CSS variables already in index.css
const AGENT_ROLE_COLORS: Record<string, string> = {
"ceo": "bg-[hsl(var(--chart-1))] text-white",
"pm": "bg-[hsl(var(--chart-2))] text-white",
"engineer": "bg-[hsl(var(--chart-3))] text-white",
"general": "bg-[hsl(var(--chart-4))] text-white",
"brainstormer": "bg-[hsl(var(--chart-5))] text-white",
};
The chart-1 through chart-5 CSS variables are already defined for all three themes in ui/src/index.css — verified in Phase 21 research. This guarantees THEME-03 compliance without new CSS.
Anti-Patterns to Avoid
- Never buffer full SSE response in middleware: Express compression middleware can buffer SSE. Ensure
compression(if present) is bypassed fortext/event-streamcontent type, or configureres.flushHeaders()before any potential buffering. - Never store streaming state in TanStack Query cache: Streaming partial content lives in
useStatelocally in the hook. Only the persisted final message enters the query cache viainvalidateQuerieson done. - Never use
EventSourcefor POST requests: EventSource is GET-only. Use the two-step pattern: POST user message → GET stream endpoint. - Never manually estimate row heights for virtua:
virtuameasures automatically. PassingitemSizeprop overrides this and breaks variable-height rows. - Never join agent table in listMessages route: Resolve agent identity client-side using cached
useAgents()data to keep the message API stable and avoid N+1 joins.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Token streaming to browser | Custom chunked transfer encoding, WebSocket | SSE with res.write() + text/event-stream |
SSE is HTTP/1.1 compatible, auto-reconnects on client, simpler than WebSocket for unidirectional streams |
| LLM provider streaming | Raw fetch loop parsing data: lines |
ai SDK or openai SDK's built-in async iterator |
Handles partial JSON, error cases, usage reporting, abort signals |
| Variable-height virtualized list | Manual IntersectionObserver + position tracking | virtua <VList> |
Auto-measurement, scroll restoration, smooth for 1,000+ items |
| Agent color assignment | Per-agent color stored in DB | CSS custom property chart-N vars by role |
Already theme-aware; no DB writes; deterministic |
Key insight: The SSE pattern already exists in server/src/routes/plugins.ts:1096-1180 — copy the header setup verbatim. The only novel work is the LLM call itself.
Common Pitfalls
Pitfall 1: SSE Buffering in Production
What goes wrong: Tokens arrive at client in large batches instead of individually; PERF-02 (100ms latency) is violated.
Why it happens: Any buffering middleware between Express and the socket — gzip compression, Nginx proxy buffering, Node's own stream buffering.
How to avoid: Add X-Accel-Buffering: no header (disables Nginx buffering), call res.flushHeaders() immediately after writeHead, use res.write() not res.json(). Pattern confirmed in existing plugin SSE route.
Warning signs: Browser receives multiple tokens in one SSE event despite server writing them individually.
Pitfall 2: EventSource Cannot POST
What goes wrong: Attempting to stream a POST request via EventSource, resulting in the browser sending a GET with no body and the server returning 404 or 405.
Why it happens: The EventSource Web API spec only allows GET requests.
How to avoid: Use the two-step pattern: POST user message → GET SSE stream. Or use @microsoft/fetch-event-source which supports POST (adds ~4KB). The two-step approach is zero-dependency and fits the existing addMessage service method.
Warning signs: SSE stream opens immediately with 0 bytes; server log shows GET instead of POST.
Pitfall 3: Optimistic Message Flicker on Done
What goes wrong: When done event fires, TanStack Query invalidation causes a re-fetch; the optimistic streaming message disappears briefly before the persisted one appears.
Why it happens: invalidateQueries clears the cache; the re-fetched list comes back from server with the final message; but there is a render cycle between clear and repopulate.
How to avoid: On done, use queryClient.setQueryData to append the final message to the existing cache pages before invalidating, OR keep the streaming message as a "pending" item that is replaced (not removed) when the server message arrives keyed by a stable temporary ID.
Warning signs: Visible flash/blank where the assistant message was during the transition from streaming to persisted.
Pitfall 4: Stop Generation Leaves Partial Message in DB
What goes wrong: User clicks Stop; server detects disconnect but has already started writing the partial response; a partial assistant message is persisted.
Why it happens: The addMessage call at end of stream only fires on clean completion; but if the abort fires mid-stream, nothing persists — this is actually fine. The pitfall is if you persist after each token instead of at the end.
How to avoid: Only call svc.addMessage for the assistant response AFTER the loop completes normally. If aborted === true after the loop, do NOT persist (or persist with a stopped: true metadata flag if partial history is desired).
Warning signs: Orphaned partial messages in the DB with no natural ending.
Pitfall 5: virtua List Auto-Scroll Conflicts with User Scroll
What goes wrong: As new tokens stream in and the list grows, virtua auto-scrolls to the bottom even when the user has deliberately scrolled up to read earlier messages.
Why it happens: Naive "scroll to bottom on new message" effect doesn't detect whether the user has scrolled away.
How to avoid: Track isAtBottom state using virtua's onScroll callback (scrollOffset + clientHeight >= scrollSize - threshold). Only auto-scroll when isAtBottom === true.
Warning signs: User scrolls up; list jumps back to bottom each time a token arrives.
Pitfall 6: Slash Command Collides with Regular Message Content
What goes wrong: A message starting with / but not matching any command is silently dropped or misrouted.
Why it happens: Overly aggressive prefix matching.
How to avoid: Only route if the prefix exactly matches a known command token followed by whitespace or end-of-string. Unknown / prefixes pass through as plain text.
Warning signs: Users report messages starting with /path/to/something being dropped.
Code Examples
SSE Headers Setup (verified pattern from existing codebase)
// Source: server/src/routes/plugins.ts:1146
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders();
res.write(":ok\n\n"); // initial comment to establish connection
SSE Event Format
// Each token as a named event
res.write(`data: ${JSON.stringify({ type: "token", content: deltaText })}\n\n`);
// Done event with persisted message ID
res.write(`data: ${JSON.stringify({ type: "done", messageId: assistantMsg.id })}\n\n`);
// Error event
res.write(`data: ${JSON.stringify({ type: "error", message: "LLM call failed" })}\n\n`);
res.end();
Client EventSource Consumption
// Source pattern: ui/src/plugins/bridge.ts:423 (adapted for GET stream)
const source = new EventSource(`/api/conversations/${convId}/stream?triggerMessageId=${msgId}`, {
withCredentials: true,
});
source.onmessage = (event) => {
const parsed = JSON.parse(event.data) as StreamEvent;
if (parsed.type === "token") {
setPartialContent((prev) => prev + parsed.content);
} else if (parsed.type === "done") {
source.close();
queryClient.invalidateQueries({ queryKey: ["chat", "messages", convId] });
setPartialContent("");
setStreaming(false);
}
};
DB Schema Additions
// packages/db/src/schema/chat_messages.ts — additive columns only
editedContent: text("edited_content"), // null if never edited
editedAt: timestamp("edited_at", { withTimezone: true }), // null if never edited
Agent Color by Role
// ui/src/lib/agent-colors.ts (new file)
const ROLE_COLOR_CLASS: Record<string, string> = {
ceo: "bg-[hsl(var(--chart-1))]",
pm: "bg-[hsl(var(--chart-2))]",
engineer: "bg-[hsl(var(--chart-3))]",
general: "bg-[hsl(var(--chart-4))]",
brainstormer: "bg-[hsl(var(--chart-5))]",
};
export function agentRoleColorClass(role: string): string {
return ROLE_COLOR_CLASS[role] ?? "bg-muted";
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| WebSocket for chat streaming | SSE (Server-Sent Events) | Industry settled ~2022-2023 | SSE is simpler, HTTP/1.1 native, no WS handshake; sufficient for unidirectional token stream |
react-window for virtualization |
virtua or @tanstack/react-virtual |
2023-2024 | react-window is unmaintained; virtua is the current simple-API choice |
| Fixed-height virtual lists | Auto-measuring virtual lists (virtua) | 2022+ | Chat messages are variable height; auto-measurement is required |
| Manual LLM token streaming | ai Vercel SDK / openai SDK async iterators |
2023+ | SDKs handle edge cases; manual stream parsing is fragile |
Deprecated/outdated:
react-window: No updates since 2021. Do not use.- Manual SSE parsing with split/newline logic: Use SDK's async iterator instead.
Open Questions
-
Which LLM provider + model backs the chat stream?
- What we know: The adapters are all subprocess wrappers, not direct API callers. No
openai/@anthropic-ai/sdkin server dependencies. - What's unclear: Does the user have a local Ollama instance? A Claude API key? An OpenAI key? The adapter config on each agent is the canonical source (
adapterConfigJSON field). - Recommendation: For the initial streaming implementation, read
agent.adapterConfig.modelandagent.adapterConfig.apiKey(or resolve viasecretService) at stream time. The simplest path is to use theopenainpm package with configurablebaseURLto support both OpenAI and Ollama-compatible endpoints. Defer multi-provider abstraction to a later phase.
- What we know: The adapters are all subprocess wrappers, not direct API callers. No
-
Should streaming use GET or POST for the SSE endpoint?
- What we know: Native
EventSourceis GET-only. The plugin bridge uses GET withcompanyIdquery param. The user message can be pre-persisted via the existing POST/messagesendpoint. - What's unclear: Whether it's cleaner to POST user message then GET stream, vs. use
@microsoft/fetch-event-sourcefor a single POST. - Recommendation: Two-step (POST message, GET stream). Zero new dependencies. Pattern matches existing plugin bridge.
- What we know: Native
-
Should slash commands create a new conversation or send to the active one?
- What we know: ROADMAP says "route messages to the correct agent" — routing, not branching.
- What's unclear: Whether
/ask-engineer Helloin a PM conversation should switch the conversation's agent or just send this one message to engineer without changing the conversation default. - Recommendation: Per-message override — slash commands and @mentions override the agent for that single message only, without changing
conversation.agentId. The agent selector (CHAT-08) is the persistent per-conversation setting.
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Node.js | Server SSE route | ✓ | Current | — |
| PostgreSQL | DB migrations | ✓ | Confirmed Phase 21 | — |
ai (Vercel AI SDK) |
LLM streaming | ✗ | Not installed | Use openai npm package directly |
virtua |
PERF-03 list | ✗ | Not installed | Defer virtualization or use @tanstack/react-virtual |
| LLM API endpoint | CHAT-01 streaming | Unknown | — | Phase 22 should add configuration step or fallback echo mode |
Missing dependencies with no fallback:
- LLM API endpoint/key: Phase 22 implementation must either read from agent
adapterConfigor prompt the user for an API key. Without this, streaming returns no tokens. Planner should include a task to read agentadapterConfig.apiKey+adapterConfig.baseUrl+adapterConfig.model.
Missing dependencies with fallback:
aiSDK: If Vercel AI SDK proves problematic, useopenainpm package (v6.33.0 confirmed available) which supports async iteration over token streams and is already the defacto standard.virtua: If not available,@tanstack/react-virtualv3.13.23 is the fallback (confirmed installable).
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Vitest 3.0.x |
| Config file | server/vitest.config.ts, ui/vitest.config.ts |
| Quick run command | pnpm --filter @paperclipai/server test run |
| Full suite command | pnpm test run |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| CHAT-01 | SSE route returns text/event-stream content-type |
unit (server) | pnpm --filter @paperclipai/server test run -- --reporter=verbose chat-stream |
❌ Wave 0 |
| CHAT-01 | SSE route emits token events then done event | unit (server) | same | ❌ Wave 0 |
| CHAT-08 | PATCH /conversations/:id accepts agentId field |
unit (server) | pnpm --filter @paperclipai/server test run -- --reporter=verbose chat-routes |
✅ exists (extend) |
| CHAT-10 | PUT /messages/:id updates editedContent + editedAt | unit (server) | pnpm --filter @paperclipai/server test run -- --reporter=verbose chat-routes |
✅ exists (extend) |
| CHAT-12 | Stop button renders during streaming | unit (UI) | pnpm --filter @paperclipai/ui test run |
❌ Wave 0 |
| CHAT-12 | Server disconnects client closes SSE gracefully | unit (server) | pnpm --filter @paperclipai/server test run -- chat-stream |
❌ Wave 0 |
| INPUT-05 | Slash command parsing extracts target role | unit (UI) | pnpm --filter @paperclipai/ui test run |
❌ Wave 0 |
| INPUT-06 | @mention parsing extracts target name | unit (UI) | pnpm --filter @paperclipai/ui test run |
❌ Wave 0 |
| AGENT-04 | ChatAgentBadge renders agent name + icon | unit (UI) | pnpm --filter @paperclipai/ui test run |
❌ Wave 0 |
| PERF-03 | ChatMessageList renders with VList | unit (UI) | pnpm --filter @paperclipai/ui test run |
✅ exists (extend) |
Sampling Rate
- Per task commit:
pnpm --filter @paperclipai/server test run && pnpm --filter @paperclipai/ui test run - Per wave merge:
pnpm test run - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
server/src/__tests__/chat-stream-routes.test.ts— covers CHAT-01, CHAT-12 SSE behaviorui/src/components/ChatInput.slash-mention.test.tsx— covers INPUT-05, INPUT-06 parsingui/src/components/ChatAgentBadge.test.tsx— covers AGENT-04 rendering
(Existing chat-routes.test.ts covers CHAT-08 and CHAT-10 with extensions; no new file needed.)
Sources
Primary (HIGH confidence)
- Codebase:
server/src/routes/plugins.ts:1096-1180— SSE header setup, write pattern, unsubscribe on close - Codebase:
server/src/services/live-events.ts— event bus pattern - Codebase:
ui/src/plugins/bridge.ts:403-460— EventSource hook pattern - Codebase:
packages/db/src/schema/chat_messages.ts,chat_conversations.ts— current schema - Codebase:
packages/shared/src/types/agent.ts— Agent type withname,icon,rolefields - Codebase:
ui/src/components/AgentIconPicker.tsx,ui/src/lib/agent-icons.ts— existing icon system - npm:
npm view virtua version→ 0.49.0 (confirmed 2026-04-01) - npm:
npm view @tanstack/react-virtual version→ 3.13.23 (confirmed 2026-04-01)
Secondary (MEDIUM confidence)
- npm:
npm view openai version→ 6.33.0 — confirmed installable, async iterator streaming available - npm:
npm view @anthropic-ai/sdk version→ 0.81.0 — confirmed installable alternative - virtua README:
<VList>API is a single component,reffor scroll programmatic control,onScrollfor position tracking
Tertiary (LOW confidence)
- Vercel AI SDK version: The
ainpm package returned v6.0.142 from npm view, which may be a different package entirely. Validate before installing — checknpm view aiauthor field or useopenaidirectly as the safer option.
Metadata
Confidence breakdown:
- Standard stack: HIGH — all existing packages verified from package.json; new packages confirmed via npm view
- Architecture: HIGH — SSE pattern directly copied from existing plugin bridge; EventSource pattern from existing bridge.ts
- Pitfalls: HIGH — derived from codebase analysis (existing SSE headers, EventSource limitations, React query cache behavior)
- LLM provider selection: LOW — depends on what API keys/models the user has configured on their agents; needs resolution in first plan wave
Research date: 2026-04-01 Valid until: 2026-05-01 (stable libraries; Vercel AI SDK version claim needs validation before install)