nexus/.planning/phases/22-agent-streaming/22-RESEARCH.md
2026-04-01 14:13:44 +02:00

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 package ai v6 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 ai package at v6.x is unrelated to the Vercel AI SDK. The correct Vercel AI SDK packages are ai (the core, currently 4.x) published by vercel-ai, and provider adapters like @ai-sdk/openai, @ai-sdk/anthropic. Verify: npm view ai should show vercel-ai as author. If the version returned is 6.x and not by vercel, use @ai-sdk/core + @ai-sdk/openai directly instead. The alternative is to use the openai npm package (v6.33.0) directly with manual SSE streaming — simpler, zero extra dependencies, proven pattern.


Architecture Patterns

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 EventSource only supports GET requests. The recommended pattern is: (1) POST the user message as normal (returns message ID), (2) open a GET SSE endpoint GET /conversations/:id/stream?triggerMessageId=X which streams the assistant reply. This avoids the need for @microsoft/fetch-event-source and keeps auth simple (GET with session cookie works in local_trusted mode).

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 for text/event-stream content type, or configure res.flushHeaders() before any potential buffering.
  • Never store streaming state in TanStack Query cache: Streaming partial content lives in useState locally in the hook. Only the persisted final message enters the query cache via invalidateQueries on done.
  • Never use EventSource for POST requests: EventSource is GET-only. Use the two-step pattern: POST user message → GET stream endpoint.
  • Never manually estimate row heights for virtua: virtua measures automatically. Passing itemSize prop 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

  1. 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/sdk in 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 (adapterConfig JSON field).
    • Recommendation: For the initial streaming implementation, read agent.adapterConfig.model and agent.adapterConfig.apiKey (or resolve via secretService) at stream time. The simplest path is to use the openai npm package with configurable baseURL to support both OpenAI and Ollama-compatible endpoints. Defer multi-provider abstraction to a later phase.
  2. Should streaming use GET or POST for the SSE endpoint?

    • What we know: Native EventSource is GET-only. The plugin bridge uses GET with companyId query param. The user message can be pre-persisted via the existing POST /messages endpoint.
    • What's unclear: Whether it's cleaner to POST user message then GET stream, vs. use @microsoft/fetch-event-source for a single POST.
    • Recommendation: Two-step (POST message, GET stream). Zero new dependencies. Pattern matches existing plugin bridge.
  3. 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 Hello in 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 adapterConfig or prompt the user for an API key. Without this, streaming returns no tokens. Planner should include a task to read agent adapterConfig.apiKey + adapterConfig.baseUrl + adapterConfig.model.

Missing dependencies with fallback:

  • ai SDK: If Vercel AI SDK proves problematic, use openai npm 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-virtual v3.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 behavior
  • ui/src/components/ChatInput.slash-mention.test.tsx — covers INPUT-05, INPUT-06 parsing
  • ui/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 with name, icon, role fields
  • 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, ref for scroll programmatic control, onScroll for position tracking

Tertiary (LOW confidence)

  • Vercel AI SDK version: The ai npm package returned v6.0.142 from npm view, which may be a different package entirely. Validate before installing — check npm view ai author field or use openai directly 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)