32 KiB
Phase 23: Brainstormer Flow - Research
Researched: 2026-04-01 Domain: Chat agent orchestration, structured messaging, SSE streaming, DB migration, React component extension Confidence: HIGH
Summary
Phase 23 builds the Brainstormer's end-to-end flow on top of the chat infrastructure delivered in Phases 21–22. The core work is: (1) a DB migration adding message_type to chat_messages, (2) new server routes for handoff and spec-to-task promotion, (3) four new UI components that render structured system role messages, and (4) wiring the default agent selector to the general role.
The Phase 22 echo stub (streamEcho) is explicitly designed to be replaced in Phase 23 with a real LLM adapter. However, the UI-SPEC.md clarifies that the Brainstormer's structured questioning flow is entirely server-side (system prompt + LLM). The UI simply renders whatever message type the server returns. This means Phase 23 does not require a real LLM to function — the spec card, handoff, and task creation flows are all triggered by explicit API calls from the UI, not by the LLM output type changing.
The entire flow works inside the existing ChatPanel without new pages or navigation. The success criteria are fully achievable by extending existing patterns established in Phases 21–22.
Primary recommendation: Extend existing patterns exactly — no new routing, no new state machines. Add message_type to DB, extend ChatMessage with a type-dispatch branch, add a /handoff route that creates a system message + triggers PM agent, and add a /handoff/complete route that creates tasks via the existing issuesApi.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
All implementation choices are at Claude's discretion — discuss phase was skipped per user setting. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.
Claude's Discretion
All implementation choices are at Claude's discretion.
Deferred Ideas (OUT OF SCOPE)
None — discuss phase skipped. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| AGENT-01 | Default agent is the Brainstormer (Generalist with a Superpowers-style system prompt, or a dedicated 4th Brainstormer agent) | useBrainstormerDefault hook queries workspace agents and selects the first role === "general" agent; wired into ChatPanel when activeAgentId === null and no messages |
| AGENT-02 | Brainstormer follows a structured questioning flow: asks clarifying questions, produces a spec template, and hands off to PM | Server-side: system prompt drives the LLM behavior. UI side: spec card triggers via message_type: "spec_card" — ChatSpecCard component renders inside ChatMessage. Edit mode enabled. "Send to PM" posts to new /handoff route |
| AGENT-03 | PM agent can receive specs from chat and create Nexus tasks/issues from them | New POST /api/conversations/:id/handoff route receives spec content + targetRole: "pm", creates system handoff message, inserts tasks via existing issueService.createIssue(), returns task IDs |
| AGENT-05 | Handoff indicators visible in chat: "Brainstormer → PM: Here's the spec for approval" | ChatHandoffIndicator component — separator-style, rendered when messageType === "handoff" |
| AGENT-06 | Task creation from chat: user or agent can say "create a task for this" and it becomes a Nexus issue | Route creates issues via issuesApi.create() pattern (same as existing UI); returns issue id, identifier, title for ChatTaskCreatedBadge |
| AGENT-07 | Status updates from agents appear in chat: "Engineer completed task X" notification in the relevant conversation | ChatStatusUpdateBadge component renders message_type: "status_update" messages. Server-side: agent completion events insert system messages into the relevant conversation |
| CHAT-09 | System message indicator: when the Brainstormer hands off to PM, or PM delegates to Engineer, the handoff is visible in chat | ChatHandoffIndicator is the implementation. DB column message_type: "handoff" identifies these messages across sessions |
| </phase_requirements> |
Standard Stack
Core — Everything Already Installed
| Library | Version | Purpose | Phase 23 Usage |
|---|---|---|---|
| drizzle-orm | existing | ORM + migrations | ALTER TABLE chat_messages ADD COLUMN message_type text |
| @tanstack/react-query | existing | Server state, cache invalidation | useQuery in useBrainstormerDefault, mutations for handoff |
| lucide-react | ^0.574.0 | Icon set | CheckCircle2 (status badge), Brain (brainstormer icon) |
| shadcn/ui | new-york preset | UI primitives | button, card, textarea already installed — no new installs |
| express | existing | Server routing | New /handoff route on existing chatRoutes |
| zod | existing | Schema validation | New handoffSchema in @paperclipai/shared validators |
No new npm packages required for Phase 23. All dependencies exist from Phases 21–22.
New Components to Build
| Component | File Path | Renders When |
|---|---|---|
ChatSpecCard |
ui/src/components/ChatSpecCard.tsx |
messageType === "spec_card" |
ChatHandoffIndicator |
ui/src/components/ChatHandoffIndicator.tsx |
messageType === "handoff" |
ChatTaskCreatedBadge |
ui/src/components/ChatTaskCreatedBadge.tsx |
messageType === "task_created" |
ChatStatusUpdateBadge |
ui/src/components/ChatStatusUpdateBadge.tsx |
messageType === "status_update" |
useBrainstormerDefault |
ui/src/hooks/useBrainstormerDefault.ts |
Used in ChatPanel on mount |
Existing Files to Modify
| File | Change Summary |
|---|---|
packages/db/src/schema/chat_messages.ts |
Add messageType: text("message_type") nullable column |
packages/db/src/migrations/NNNN_add_message_type.sql |
Raw SQL: ALTER TABLE "chat_messages" ADD COLUMN "message_type" text; |
packages/shared/src/types/chat.ts |
Add messageType: string | null to ChatMessage interface |
packages/shared/src/validators/chat.ts |
Add messageType to createMessageSchema; add handoffSchema |
server/src/routes/chat.ts |
Add POST /conversations/:id/handoff route |
server/src/services/chat.ts |
Add addSystemMessage() helper; extend addMessage() to accept messageType |
ui/src/components/ChatMessage.tsx |
Add messageType prop; dispatch to specialized components |
ui/src/components/ChatMessageList.tsx |
Pass messageType from stored message to ChatMessage |
ui/src/components/ChatPanel.tsx |
Wire useBrainstormerDefault for auto-selection |
ui/src/api/chat.ts |
Add handoffSpec() method, patchMessage() for spec edits |
Architecture Patterns
Recommended Project Structure (Phase 23 additions only)
packages/
├── db/src/
│ ├── schema/chat_messages.ts ← add messageType column
│ └── migrations/NNNN_add_message_type.sql
├── shared/src/
│ ├── types/chat.ts ← add messageType to ChatMessage
│ └── validators/chat.ts ← add messageType, handoffSchema
server/src/
├── routes/chat.ts ← add POST /conversations/:id/handoff
└── services/chat.ts ← extend addMessage, add addSystemMessage
ui/src/
├── components/
│ ├── ChatMessage.tsx ← extend with messageType dispatch
│ ├── ChatMessageList.tsx ← pass messageType prop
│ ├── ChatPanel.tsx ← wire useBrainstormerDefault
│ ├── ChatSpecCard.tsx ← NEW
│ ├── ChatHandoffIndicator.tsx ← NEW
│ ├── ChatTaskCreatedBadge.tsx ← NEW
│ └── ChatStatusUpdateBadge.tsx ← NEW
└── hooks/
└── useBrainstormerDefault.ts ← NEW
Pattern 1: DB Migration (SQL File, Not TypeScript)
The project uses raw SQL migration files, not TypeScript migrations. Look at the existing pattern:
-- packages/db/src/migrations/NNNN_add_message_type.sql
ALTER TABLE "chat_messages" ADD COLUMN "message_type" text;
After adding the SQL file, update the Drizzle schema in packages/db/src/schema/chat_messages.ts:
// Source: existing packages/db/src/schema/chat_messages.ts
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import { chatConversations } from "./chat_conversations.js";
export const chatMessages = pgTable(
"chat_messages",
{
id: uuid("id").primaryKey().defaultRandom(),
conversationId: uuid("conversation_id").notNull()
.references(() => chatConversations.id, { onDelete: "cascade" }),
role: text("role").notNull(),
content: text("content").notNull(),
agentId: uuid("agent_id"),
messageType: text("message_type"), // NEW: null | "handoff" | "spec_card" | "task_created" | "status_update"
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
},
(table) => ({
conversationCreatedIdx: index("chat_messages_conversation_created_idx").on(table.conversationId, table.createdAt),
}),
);
CRITICAL: The migration numbering. The journal shows entries up to idx 46 (tag: 0046_smooth_sentinels), but files on disk go up to 0048_add_chat_messages_updated_at.sql. The next migration must be named 0049_*.sql with idx 49 added to _journal.json.
Migration journal update required: _journal.json must be manually updated with a new entry. The generate script uses drizzle-kit which reads compiled schema from dist/ — but since we are adding raw SQL manually (matching the existing pattern for Phase 21/22 chat migrations), we append both the SQL file and a journal entry.
Pattern 2: ChatMessage Type Dispatch
Extend ChatMessage with a messageType prop and a dispatch block before the existing role === "user" check:
// Source: existing ui/src/components/ChatMessage.tsx — extend this pattern
interface ChatMessageProps {
// ... existing props ...
messageType?: string | null;
}
export function ChatMessage({ role, content, messageType, ...rest }) {
// Dispatch to specialized system message components
if (role === "system" || messageType) {
if (messageType === "spec_card") return <ChatSpecCard content={content} ... />;
if (messageType === "handoff") return <ChatHandoffIndicator content={content} />;
if (messageType === "task_created") return <ChatTaskCreatedBadge content={content} />;
if (messageType === "status_update") return <ChatStatusUpdateBadge content={content} />;
}
// ... existing user / assistant rendering ...
}
The content field for structured messages stores JSON. ChatSpecCard parses JSON.parse(content) to extract { what, why, constraints, success }. Parse errors fall back to "Could not render spec." in text-destructive text-[13px].
Pattern 3: Handoff Route
New route on the existing chatRoutes router:
// POST /api/conversations/:id/handoff
router.post("/conversations/:id/handoff", async (req, res) => {
assertBoard(req);
const data = handoffSchema.parse(req.body);
// 1. Insert system message with messageType: "handoff"
const handoffMsg = await svc.addSystemMessage(req.params.id!, {
content: "Brainstormer → PM: spec handed off",
messageType: "handoff",
});
// 2. Create Nexus issues from spec via issueService
const issues = await issueSvc.createFromSpec(data.spec, req.params.companyId!);
// 3. Insert system message with messageType: "task_created" for each issue
for (const issue of issues) {
await svc.addSystemMessage(req.params.id!, {
content: JSON.stringify({ taskId: issue.identifier, taskTitle: issue.title, taskUrl: `/issues/${issue.id}` }),
messageType: "task_created",
});
}
res.json({ handoffMessageId: handoffMsg.id, issues });
});
The companyId must be resolved from the conversation. The chatService.getConversation() method returns the full row including companyId. Use that to call issueService.
Pattern 4: useBrainstormerDefault Hook
// ui/src/hooks/useBrainstormerDefault.ts
import { useQuery } from "@tanstack/react-query";
import { agentsApi } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
export function useBrainstormerDefault() {
const { selectedCompanyId } = useCompany();
const { data: agents = [] } = useQuery({
queryKey: ["agents", selectedCompanyId],
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
// ChatPanel already runs this same query — React Query deduplicates the fetch
const generalAgent = agents
.filter((a) => a.role === "general")
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())[0];
return generalAgent?.id ?? null;
}
Cache deduplication: ChatPanel already queries ["agents", selectedCompanyId]. The hook reuses the same query key, so no extra network request fires.
Pattern 5: Spec Card Edit Mode
ChatSpecCard manages its own edit state locally. It does NOT use useStreamingChat or useChatMessages. Edit → "Save changes" calls chatApi.patchMessage(conversationId, messageId, newContent) which maps to the existing PATCH /api/conversations/:id/messages/:msgId route. No new server endpoints needed for edit.
// Spec card content schema
interface SpecContent {
what: string;
why: string;
constraints: string;
success: string;
}
// Stored in chat_messages.content as JSON.stringify(SpecContent)
Pattern 6: Status Update Insertion (AGENT-07)
AGENT-07 says "Status updates from agents appear in chat." In Phase 23, the trigger for status updates is not implemented end-to-end (that requires the LLM adapter replacement from Phase 22's note: "Phase 23 replaces with real LLM adapter"). The UI side (rendering ChatStatusUpdateBadge) is fully implemented. The server-side trigger can be a new POST /api/conversations/:id/status-update route that any system component can call to insert a system message with messageType: "status_update".
For Phase 23 scope: implement the UI component and the insertion route. The actual agent-to-chat notification plumbing can be minimal (the route exists; agent adapter wiring is Phase 24+).
Anti-Patterns to Avoid
- Storing spec fields as separate DB columns: The spec content (
what,why,constraints,success) goes intochat_messages.contentas JSON. No new columns. This is consistent with how structured data is handled elsewhere. - New React Router pages for the handoff flow: The UI-SPEC is explicit — everything stays in
ChatPanel. No new routes. - Re-fetching all messages after handoff: Use optimistic insertion + targeted invalidation. Append the handoff + task badge messages locally first (optimistic), then invalidate
["chat", "messages", conversationId]on success. - Modifying
useStreamingChat: The spec card and handoff flow are triggered by explicit button clicks, not by streaming. Do not add streaming state to spec card actions. - Spec card inside streaming entry: Spec cards are stored messages (from DB), not streaming content. They always appear as real
ChatMessageentries withrole === "system".
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Issue creation | Custom issue schema | issuesApi.create(companyId, data) via existing route POST /companies/:companyId/issues |
Existing endpoint handles all business logic, status defaults, identifier generation |
| Optimistic UI updates | Manual cache state | queryClient.setQueryData + queryClient.invalidateQueries (established Phase 21/22 pattern) |
React Query cache is already wired for chat messages |
| Toast notifications | Custom toast component | shadcn toast (already wired via Sonner in the app) |
Existing toast infrastructure from Phase 21 |
| JSON content parsing error boundary | Custom error component | Inline try/catch with fallback render in ChatSpecCard |
Single component, single location |
| Agent lookup | New API call | agentMap already built in ChatPanel and passed to ChatMessageList |
Zero additional fetches |
Key insight: The entire Phase 23 feature surface is achievable by extending existing patterns. The only net-new infrastructure is the DB migration, the /handoff route, and the four UI components.
Common Pitfalls
Pitfall 1: Migration File Numbering Mismatch
What goes wrong: The _journal.json lists entries up to idx 46, but disk has files 0047 and 0048. If the new migration is numbered incorrectly, drizzle-kit will fail to apply or generate a gap.
Why it happens: The journal and the files can drift when migrations are added manually without running drizzle-kit generate.
How to avoid: Check the last file number on disk (ls src/migrations/*.sql | tail -1) and use the next sequential number. Also add the corresponding journal entry with matching idx, version: "7", when: Date.now(), tag, and breakpoints: true.
Warning signs: drizzle-kit migrate complains about missing or duplicate journal entries.
Pitfall 2: messageType Not Propagated Through Message List
What goes wrong: ChatMessageList renders ChatMessage but only passes fields that are currently in ChatMessage — adding messageType to the DB schema and shared type does not automatically flow to the component.
Why it happens: The ChatMessage component props interface must be extended, and ChatMessageList must read msg.messageType from the message object and pass it down.
How to avoid: Update the props interface in ChatMessage.tsx, update ChatMessageList.tsx to pass messageType={msg.messageType}, and update the synthetic streaming entry object to include messageType: null.
Warning signs: Spec cards appear as raw JSON text in the chat.
Pitfall 3: Spec Card "Send to PM" Race Condition (Optimistic + Failure)
What goes wrong: The optimistic ChatHandoffIndicator is appended before the API call. If the call fails, the indicator must be removed — but if the component is purely display-driven by the messages array, removing it requires either a local state flag or removing the optimistically inserted message.
Why it happens: Optimistic UI requires rollback on failure.
How to avoid: Track the handoff submission state in ChatSpecCard with a local submitting flag. The optimistic indicator should be a local state element (not a persisted message) until the API succeeds. On success, the server-inserted handoff message causes React Query to re-fetch and display the persisted version. On failure, the local state is cleared and buttons are re-enabled with a toast.
Warning signs: Ghost handoff indicators appearing after failed API calls, or spec cards being stuck in "submitting" state.
Pitfall 4: companyId Not Available in Chat Route for Issue Creation
What goes wrong: POST /api/conversations/:id/handoff needs companyId to call issueService.createFromSpec(), but the route parameter is only :id (conversation ID).
Why it happens: The chat routes are conversation-scoped, not company-scoped.
How to avoid: Call svc.getConversation(req.params.id!) at the start of the handoff route to resolve companyId from the conversation row. This is a single extra DB read and follows the established pattern.
Warning signs: 500 errors on handoff with "companyId undefined".
Pitfall 5: Virtualizer Height with System Messages
What goes wrong: System message components (spec cards, task badges) have different heights than standard prose messages. The virtualizer uses an estimated height of 80px (from Phase 22). Spec cards will be taller; task badges will be shorter.
Why it happens: estimateSize: () => 80 is a fixed estimate; actual heights vary.
How to avoid: The virtualizer already uses measureElement and virtualizer.measure() — the dynamic measurement from Phase 22 handles this. No additional work needed as long as the system message components are inside the virtualizer's ref={virtualizer.measureElement} wrapper.
Warning signs: Messages overlapping or large gaps between messages after a spec card renders.
Pitfall 6: Spec Content JSON Parse in Streaming Context
What goes wrong: If the streaming echo stub produces content that happens to have messageType: "spec_card" set (e.g., during testing), the content will be raw text, not JSON, and JSON.parse will throw.
Why it happens: The spec card branch in ChatMessage fires before content is verified as valid JSON.
How to avoid: Always wrap the content parse in try/catch in ChatSpecCard. The fallback render is "Could not render spec." in text-destructive text-[13px] per the UI-SPEC.
Warning signs: White screen or unhandled error in ChatMessage during development.
Code Examples
DB Schema Extension Pattern (verified from existing codebase)
// Source: packages/db/src/schema/chat_messages.ts — existing file
// Add messageType column using the same text() pattern as role/content
messageType: text("message_type"),
addSystemMessage Service Helper
// Source: server/src/services/chat.ts — extend existing addMessage pattern
async addSystemMessage(
conversationId: string,
data: { content: string; messageType: string; agentId?: string },
) {
const [message] = await db
.insert(chatMessages)
.values({
conversationId,
role: "system",
content: data.content,
agentId: data.agentId ?? null,
messageType: data.messageType,
})
.returning();
await db
.update(chatConversations)
.set({ updatedAt: new Date() })
.where(eq(chatConversations.id, conversationId));
return message!;
},
ChatHandoffIndicator Layout (from UI-SPEC)
// Source: 23-UI-SPEC.md
export function ChatHandoffIndicator({ content }: { content: string }) {
return (
<div
className="flex items-center gap-3 py-2 text-[13px] text-muted-foreground"
aria-label="Agent handoff from Brainstormer to PM"
>
<hr className="flex-1 border-border" aria-hidden="true" />
<span className="whitespace-nowrap">{content}</span>
<hr className="flex-1 border-border" aria-hidden="true" />
</div>
);
}
ChatTaskCreatedBadge Layout (from UI-SPEC)
// Source: 23-UI-SPEC.md
export function ChatTaskCreatedBadge({ taskId, taskTitle, taskUrl }: Props) {
if (!taskId) {
return (
<div className="inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-1 text-[13px] text-muted-foreground">
Creating task...
</div>
);
}
return (
<div className="inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-1 text-[13px]" role="status">
<span className="text-[11px] font-semibold text-muted-foreground">{taskId}</span>
<span className="text-foreground">{taskTitle}</span>
<Link to={taskUrl} className="text-primary underline-offset-2 hover:underline" aria-label={`View task ${taskId}`}>
View task
</Link>
</div>
);
}
useBrainstormerDefault Hook (cache-sharing pattern)
// Source: research — matches React Query pattern from ChatPanel.tsx
// ChatPanel already runs: useQuery({ queryKey: ["agents", selectedCompanyId], ... })
// This hook reuses the SAME queryKey — no duplicate network request
export function useBrainstormerDefault(): string | null {
const { selectedCompanyId } = useCompany();
const { data: agents = [] } = useQuery({
queryKey: ["agents", selectedCompanyId],
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const generalAgent = agents
.filter((a) => a.role === "general")
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())[0];
return generalAgent?.id ?? null;
}
ChatPanel auto-selection wiring
// Source: ui/src/components/ChatPanel.tsx — extend existing pattern
// Add effect after existing state declarations:
const brainstormerDefaultId = useBrainstormerDefault();
useEffect(() => {
// Only auto-select when no agent chosen AND no messages yet (new conversation)
if (activeAgentId === null && brainstormerDefaultId !== null) {
const hasMessages = messages && messages.length > 0;
if (!hasMessages) {
setActiveAgentId(brainstormerDefaultId);
}
}
}, [activeAgentId, brainstormerDefaultId, messages]);
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
streamEcho stub (word-by-word) |
Real LLM adapter (Phase 23) | Phase 22 note: "Phase 23 replaces with real LLM adapter" | The streaming endpoint in chat.ts currently calls svc.streamEcho() — Phase 23 must replace this with actual LLM invocation. This is a significant addition, separate from the structured message flow. |
| No structured messages | message_type column + type dispatch |
Phase 23 (this phase) | System messages can now carry typed content for specialized rendering |
Important clarification on LLM integration: The STATE.md note says "streamEcho stub yields word-by-word with 50ms delay; Phase 23 replaces with real LLM adapter." This means Phase 23 should connect the streaming endpoint to an actual agent adapter. The agents table has adapterType and adapterConfig fields. The server already has adapter loading via findServerAdapter() in routes/agents.ts. The streaming route in chat.ts needs to call the agent's adapter instead of streamEcho. This is distinct from the spec card / handoff flow but is part of Phase 23's scope (AGENT-02 depends on the LLM actually producing the questioning flow).
Adapter invocation pattern: Look at how issueService or agent task sessions invoke adapters. The adapterConfig from the agent row contains the LLM connection details. For Phase 23, the simplest approach is to call the adapter's streaming generation method with the conversation history as context.
Open Questions
-
LLM Adapter Invocation in
streamEchoReplacement- What we know:
findServerAdapter()exists inserver/src/adapters/index.ts; agents haveadapterTypeandadapterConfig; existing adapters (claude_local, etc.) have streaming capability - What's unclear: The exact interface for calling an adapter's streaming generation from
chat.ts— the adapters are designed for task execution, not conversational streaming - Recommendation: Read
server/src/adapters/index.tsand one adapter's implementation before writing the streaming replacement. The planner should allocate a dedicated plan wave for this.
- What we know:
-
Issue Creation Required Fields for PM Agent
- What we know:
issuesApi.create(companyId, data)takesRecord<string, unknown>; the issues table hastitle,description,status,priority,projectId(nullable) - What's unclear: Which fields are required by the server route's validation; whether a
projectIdis needed for chat-originated tasks - Recommendation: The STATE.md Blockers section flags this exact issue for Phase 4 — "POST /api/companies/:companyId/issues required fields not fully documented — read server/src/routes/companies.ts before implementing." Read
server/src/routes/issues.tsin the plan's Wave 0 before implementing task creation.
- What we know:
-
Spec Card Content Storage Format
- What we know: Content is stored as JSON in
chat_messages.content; the existingcontentcolumn istextwithmin(1) max(100_000)validation - What's unclear: Whether the
createMessageSchemamax of 100,000 characters is sufficient for typical spec cards (yes — spec cards will be well under 1KB) - Recommendation: HIGH confidence this is fine. No action needed.
- What we know: Content is stored as JSON in
Environment Availability
Step 2.6: SKIPPED — Phase 23 is purely code/config changes. All runtime dependencies (PostgreSQL, Node.js, existing adapters) are already verified operational from Phase 22.
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Vitest 3.x |
| Config file | vitest.config.ts (root) — includes ui and server projects |
| UI config | ui/vitest.config.ts — environment: node |
| Quick run command | pnpm test:run --project=ui |
| Full suite command | pnpm test:run |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| AGENT-01 | useBrainstormerDefault returns general role agent ID |
unit | pnpm test:run --project=ui -- useBrainstormerDefault |
❌ Wave 0 |
| AGENT-01 | Falls back to first alphabetical if no general agent |
unit | same | ❌ Wave 0 |
| AGENT-02 | ChatMessage renders ChatSpecCard when messageType === "spec_card" |
unit | pnpm test:run --project=ui -- ChatMessage |
✅ (extend existing) |
| AGENT-02 | ChatSpecCard parses JSON content and renders four sections |
unit | pnpm test:run --project=ui -- ChatSpecCard |
❌ Wave 0 |
| AGENT-02 | ChatSpecCard shows error fallback on JSON parse failure |
unit | same | ❌ Wave 0 |
| AGENT-02 | Edit mode: textareas appear; "Save changes" disabled when all empty | unit | same | ❌ Wave 0 |
| AGENT-03 | POST /conversations/:id/handoff inserts handoff message + task messages |
unit | pnpm test:run --project=server -- chat-routes |
✅ (extend existing) |
| AGENT-05 | ChatHandoffIndicator renders with flanking <hr> elements |
unit | pnpm test:run --project=ui -- ChatHandoffIndicator |
❌ Wave 0 |
| AGENT-06 | Task badge renders "Creating task..." before taskId | unit | pnpm test:run --project=ui -- ChatTaskCreatedBadge |
❌ Wave 0 |
| AGENT-06 | Task badge renders taskId + "View task" link after resolve | unit | same | ❌ Wave 0 |
| AGENT-07 | ChatStatusUpdateBadge renders agent name + task reference |
unit | pnpm test:run --project=ui -- ChatStatusUpdateBadge |
❌ Wave 0 |
| CHAT-09 | Handoff message stored with messageType: "handoff" |
unit | pnpm test:run --project=server -- chat-service |
✅ (extend existing) |
Sampling Rate
- Per task commit:
pnpm test:run --project=ui+pnpm test:run --project=server - Per wave merge:
pnpm test:run(full suite) - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
ui/src/hooks/useBrainstormerDefault.test.ts— covers AGENT-01ui/src/components/ChatSpecCard.test.tsx— covers AGENT-02 (spec render, edit mode, JSON error)ui/src/components/ChatHandoffIndicator.test.tsx— covers AGENT-05ui/src/components/ChatTaskCreatedBadge.test.tsx— covers AGENT-06ui/src/components/ChatStatusUpdateBadge.test.tsx— covers AGENT-07
Sources
Primary (HIGH confidence)
- Direct codebase inspection —
packages/db/src/schema/chat_messages.ts,chat_conversations.ts,agents.ts - Direct codebase inspection —
server/src/routes/chat.ts,server/src/services/chat.ts - Direct codebase inspection —
ui/src/components/ChatMessage.tsx,ChatMessageList.tsx,ChatPanel.tsx - Direct codebase inspection —
ui/src/hooks/useStreamingChat.ts,useChatMessages.ts - Direct codebase inspection —
ui/src/api/chat.ts,ui/src/api/issues.ts - Direct codebase inspection —
packages/shared/src/types/chat.ts,validators/chat.ts - Direct codebase inspection —
packages/shared/src/constants.ts(AgentRole, AGENT_ICON_NAMES) - Direct codebase inspection —
.planning/phases/23-brainstormer-flow/23-UI-SPEC.md - Direct codebase inspection —
.planning/STATE.md(Phase 22 decisions and notes) - Direct codebase inspection —
packages/db/src/migrations/(migration naming convention)
Secondary (MEDIUM confidence)
- Migration journal structure inferred from
_journal.json+ existing SQL files
Tertiary (LOW confidence)
- LLM adapter invocation interface — not inspected; flagged as Open Question 1
Metadata
Confidence breakdown:
- Standard stack: HIGH — all libraries verified present in codebase
- Architecture patterns: HIGH — directly observed from Phase 21/22 code
- DB migration: HIGH — verified file naming convention and journal format
- Pitfalls: HIGH — derived from actual code paths and Phase 22 decisions in STATE.md
- LLM adapter replacement: LOW — not inspected; flagged as open question
Research date: 2026-04-01 Valid until: 2026-05-01 (stable codebase; no fast-moving external dependencies)