662 lines
23 KiB
Markdown
662 lines
23 KiB
Markdown
---
|
|
phase: 22-agent-streaming
|
|
plan: "01"
|
|
type: execute
|
|
wave: 1
|
|
depends_on: ["22-00"]
|
|
files_modified:
|
|
- server/src/services/chat.ts
|
|
- server/src/routes/chat.ts
|
|
- ui/src/hooks/useStreamingChat.ts
|
|
- ui/src/hooks/useStreamingChat.test.ts
|
|
- ui/src/api/chat.ts
|
|
autonomous: true
|
|
requirements:
|
|
- CHAT-01
|
|
- CHAT-12
|
|
- PERF-02
|
|
must_haves:
|
|
truths:
|
|
- "Server SSE endpoint streams token events as text/event-stream"
|
|
- "Client hook accumulates tokens into streamingContent string"
|
|
- "User can stop a stream mid-generation and partial content is preserved"
|
|
- "First SSE headers are flushed before any LLM generation begins"
|
|
artifacts:
|
|
- path: "server/src/routes/chat.ts"
|
|
provides: "POST /conversations/:id/stream SSE endpoint"
|
|
contains: "text/event-stream"
|
|
- path: "server/src/services/chat.ts"
|
|
provides: "editMessage and truncateMessagesAfter methods"
|
|
contains: "editMessage"
|
|
- path: "ui/src/hooks/useStreamingChat.ts"
|
|
provides: "SSE lifecycle hook"
|
|
exports: ["useStreamingChat"]
|
|
- path: "ui/src/api/chat.ts"
|
|
provides: "postMessageAndStream method"
|
|
contains: "postMessageAndStream"
|
|
key_links:
|
|
- from: "ui/src/hooks/useStreamingChat.ts"
|
|
to: "server POST /conversations/:id/stream"
|
|
via: "fetch with ReadableStream"
|
|
pattern: "fetch.*stream"
|
|
- from: "server/src/routes/chat.ts"
|
|
to: "server/src/services/chat.ts"
|
|
via: "svc.addMessage for final commit"
|
|
pattern: "svc\\.addMessage"
|
|
---
|
|
|
|
<objective>
|
|
SSE streaming endpoint on the server and `useStreamingChat` hook on the client. The server uses a stub echo stream (repeats user message as fake tokens) since real LLM integration is Phase 23. The client uses `fetch` with `ReadableStream` (not native `EventSource`) because the stream endpoint is POST-based.
|
|
|
|
Purpose: Enable real-time token-by-token message delivery (CHAT-01), stop generation (CHAT-12), and sub-100ms first-token latency (PERF-02).
|
|
Output: Server SSE route, chat service edit/truncate methods, client streaming hook, updated chat API client.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/22-agent-streaming/22-RESEARCH.md
|
|
@.planning/phases/22-agent-streaming/22-00-SUMMARY.md
|
|
|
|
<interfaces>
|
|
From server/src/routes/chat.ts (existing):
|
|
```typescript
|
|
export function chatRoutes(db: Db): Router {
|
|
const router = Router();
|
|
const svc = chatService(db);
|
|
// ... existing routes: POST /conversations, GET /conversations/:id, etc.
|
|
}
|
|
```
|
|
|
|
From server/src/services/chat.ts (existing):
|
|
```typescript
|
|
export function chatService(db: Db) {
|
|
return {
|
|
createConversation(...), listConversations(...), getConversation(...),
|
|
updateConversation(...), softDeleteConversation(...),
|
|
listMessages(...), addMessage(...)
|
|
};
|
|
}
|
|
```
|
|
|
|
From server/src/routes/plugins.ts (SSE pattern):
|
|
```typescript
|
|
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");
|
|
```
|
|
|
|
From ui/src/api/chat.ts (existing):
|
|
```typescript
|
|
export const chatApi = {
|
|
listConversations(...), createConversation(...), getConversation(...),
|
|
updateConversation(...), deleteConversation(...), listMessages(...), postMessage(...)
|
|
};
|
|
```
|
|
|
|
From ui/src/hooks/useChatMessages.ts:
|
|
```typescript
|
|
export function useChatMessages(conversationId: string | null) {
|
|
// queryKey: ["chat", "messages", conversationId]
|
|
// sendMutation invalidates: ["chat", "messages", conversationId] and ["chat", "conversations"]
|
|
}
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Server SSE streaming endpoint + edit/truncate service methods</name>
|
|
<read_first>
|
|
- server/src/routes/chat.ts
|
|
- server/src/services/chat.ts
|
|
- server/src/routes/plugins.ts (lines 1140-1185 for SSE pattern)
|
|
- packages/db/src/schema/chat_messages.ts
|
|
</read_first>
|
|
<files>
|
|
server/src/services/chat.ts,
|
|
server/src/routes/chat.ts
|
|
</files>
|
|
<action>
|
|
**1. Add three new methods to `chatService` in `server/src/services/chat.ts`:**
|
|
|
|
a) `editMessage(messageId: string, content: string)` -- Updates a message's content and updatedAt:
|
|
```typescript
|
|
async editMessage(messageId: string, content: string) {
|
|
const [row] = await db
|
|
.update(chatMessages)
|
|
.set({ content, updatedAt: new Date() })
|
|
.where(eq(chatMessages.id, messageId))
|
|
.returning();
|
|
return row;
|
|
},
|
|
```
|
|
|
|
b) `truncateMessagesAfter(conversationId: string, messageId: string)` -- Deletes all messages in the conversation created after the given message:
|
|
```typescript
|
|
async truncateMessagesAfter(conversationId: string, messageId: string) {
|
|
// Get the target message's createdAt
|
|
const [target] = await db
|
|
.select({ createdAt: chatMessages.createdAt })
|
|
.from(chatMessages)
|
|
.where(eq(chatMessages.id, messageId));
|
|
if (!target) return;
|
|
await db
|
|
.delete(chatMessages)
|
|
.where(
|
|
and(
|
|
eq(chatMessages.conversationId, conversationId),
|
|
gt(chatMessages.createdAt, target.createdAt),
|
|
),
|
|
);
|
|
},
|
|
```
|
|
Import `gt` from `drizzle-orm` alongside existing imports.
|
|
|
|
c) `streamEcho(content: string, signal: AbortSignal)` -- Async generator that yields fake tokens (stub for Phase 23 real LLM):
|
|
```typescript
|
|
async *streamEcho(content: string, signal: AbortSignal) {
|
|
const words = content.split(/\s+/);
|
|
for (const word of words) {
|
|
if (signal.aborted) break;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
yield word + " ";
|
|
}
|
|
},
|
|
```
|
|
|
|
**2. Add three new routes to `chatRoutes` in `server/src/routes/chat.ts`:**
|
|
|
|
a) `POST /conversations/:id/stream` -- SSE streaming endpoint:
|
|
```typescript
|
|
router.post("/conversations/:id/stream", async (req, res) => {
|
|
assertBoard(req);
|
|
const { content, agentId } = req.body;
|
|
if (!content || typeof content !== "string") {
|
|
res.status(400).json({ error: "content is required" });
|
|
return;
|
|
}
|
|
|
|
// Set SSE headers and flush BEFORE any generation (PERF-02)
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
res.setHeader("X-Accel-Buffering", "no");
|
|
res.flushHeaders();
|
|
res.write(":ok\n\n");
|
|
|
|
const abort = new AbortController();
|
|
req.on("close", () => abort.abort());
|
|
|
|
try {
|
|
let fullContent = "";
|
|
for await (const token of svc.streamEcho(content, abort.signal)) {
|
|
if (!res.writable) break;
|
|
fullContent += token;
|
|
res.write(`data: ${JSON.stringify({ token })}\n\n`);
|
|
}
|
|
if (res.writable && !abort.signal.aborted) {
|
|
const message = await svc.addMessage(req.params.id!, {
|
|
role: "assistant",
|
|
content: fullContent.trim(),
|
|
agentId: agentId || undefined,
|
|
});
|
|
res.write(`data: ${JSON.stringify({ done: true, messageId: message.id, content: fullContent.trim() })}\n\n`);
|
|
}
|
|
} catch (err) {
|
|
if (res.writable && !abort.signal.aborted) {
|
|
res.write(`data: ${JSON.stringify({ error: "Stream error" })}\n\n`);
|
|
}
|
|
} finally {
|
|
res.end();
|
|
}
|
|
});
|
|
```
|
|
CRITICAL: `res.flushHeaders()` MUST be called before the for-await loop. Check `res.writable` before every `res.write()` (same guard pattern as `plugins.ts`).
|
|
|
|
b) `PATCH /conversations/:id/messages/:msgId` -- Edit message content:
|
|
```typescript
|
|
router.patch("/conversations/:id/messages/:msgId", async (req, res) => {
|
|
assertBoard(req);
|
|
const { content } = req.body;
|
|
if (!content || typeof content !== "string") {
|
|
res.status(400).json({ error: "content is required" });
|
|
return;
|
|
}
|
|
const message = await svc.editMessage(req.params.msgId!, content);
|
|
if (!message) {
|
|
res.status(404).json({ error: "Message not found" });
|
|
return;
|
|
}
|
|
res.json(message);
|
|
});
|
|
```
|
|
|
|
c) `DELETE /conversations/:id/messages/after/:msgId` -- Truncate messages after a given message:
|
|
```typescript
|
|
router.delete("/conversations/:id/messages/after/:msgId", async (req, res) => {
|
|
assertBoard(req);
|
|
await svc.truncateMessagesAfter(req.params.id!, req.params.msgId!);
|
|
res.status(204).end();
|
|
});
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus && pnpm --filter @paperclipai/server exec -- tsc --noEmit 2>&1 | head -20 && echo "--- PERF-02 flushHeaders-before-loop check ---" && python3 -c "
|
|
import re
|
|
code = open('server/src/routes/chat.ts').read()
|
|
flush_pos = code.find('flushHeaders')
|
|
loop_pos = code.find('for await')
|
|
assert flush_pos != -1, 'flushHeaders not found'
|
|
assert loop_pos != -1, 'for await not found'
|
|
assert flush_pos < loop_pos, f'PERF-02 FAIL: flushHeaders ({flush_pos}) must precede for-await ({loop_pos})'
|
|
print('PERF-02 OK: flushHeaders precedes for-await loop')
|
|
"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- grep -q "text/event-stream" server/src/routes/chat.ts
|
|
- grep -q "flushHeaders" server/src/routes/chat.ts
|
|
- grep -q "editMessage" server/src/services/chat.ts
|
|
- grep -q "truncateMessagesAfter" server/src/services/chat.ts
|
|
- grep -q "streamEcho" server/src/services/chat.ts
|
|
- grep -q "res.writable" server/src/routes/chat.ts
|
|
- grep -q "/conversations/:id/stream" server/src/routes/chat.ts
|
|
- grep -q "/conversations/:id/messages/:msgId" server/src/routes/chat.ts
|
|
- grep -q "/conversations/:id/messages/after/:msgId" server/src/routes/chat.ts
|
|
- flushHeaders() position in file must precede for-await loop position (PERF-02)
|
|
</acceptance_criteria>
|
|
<done>
|
|
- POST /conversations/:id/stream SSE endpoint exists with proper headers flushed before generation
|
|
- PATCH /conversations/:id/messages/:msgId edits message content
|
|
- DELETE /conversations/:id/messages/after/:msgId truncates subsequent messages
|
|
- chatService has editMessage, truncateMessagesAfter, and streamEcho methods
|
|
- All routes check res.writable before writing (prevents write-after-end)
|
|
- PERF-02 verified: flushHeaders precedes the for-await generation loop
|
|
- Server TypeScript compiles without errors in chat files
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: useStreamingChat hook, chat API stream method, and real unit tests</name>
|
|
<read_first>
|
|
- ui/src/hooks/useChatMessages.ts
|
|
- ui/src/api/chat.ts
|
|
- ui/src/plugins/bridge.ts
|
|
- ui/src/hooks/useStreamingChat.test.ts
|
|
</read_first>
|
|
<files>
|
|
ui/src/hooks/useStreamingChat.ts,
|
|
ui/src/hooks/useStreamingChat.test.ts,
|
|
ui/src/api/chat.ts
|
|
</files>
|
|
<action>
|
|
**1. Add stream-related methods to `chatApi` in `ui/src/api/chat.ts`:**
|
|
|
|
```typescript
|
|
async postMessageAndStream(
|
|
conversationId: string,
|
|
data: { content: string; agentId?: string },
|
|
callbacks: {
|
|
onToken: (token: string) => void;
|
|
onDone: (messageId: string, content: string) => void;
|
|
onError: (error: string) => void;
|
|
},
|
|
signal?: AbortSignal,
|
|
) {
|
|
const res = await fetch(`/api/conversations/${conversationId}/stream`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
credentials: "include",
|
|
signal,
|
|
});
|
|
if (!res.ok || !res.body) {
|
|
callbacks.onError("Failed to start stream");
|
|
return;
|
|
}
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() ?? "";
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data: ")) continue;
|
|
const json = line.slice(6);
|
|
try {
|
|
const parsed = JSON.parse(json) as { token?: string; done?: boolean; messageId?: string; content?: string; error?: string };
|
|
if (parsed.token) callbacks.onToken(parsed.token);
|
|
if (parsed.done && parsed.messageId) callbacks.onDone(parsed.messageId, parsed.content ?? "");
|
|
if (parsed.error) callbacks.onError(parsed.error);
|
|
} catch { /* ignore malformed lines */ }
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (signal?.aborted) return; // Expected on stop
|
|
callbacks.onError("Stream connection lost");
|
|
}
|
|
},
|
|
|
|
async savePartialMessage(conversationId: string, data: { role: "assistant"; content: string; agentId?: string }) {
|
|
return chatApi.postMessage(conversationId, data);
|
|
},
|
|
```
|
|
|
|
Use `fetch` with `ReadableStream` instead of `EventSource` because the endpoint is POST-based. `EventSource` only supports GET (Open Question 2 from RESEARCH.md).
|
|
|
|
**2. Create `ui/src/hooks/useStreamingChat.ts`:**
|
|
|
|
```typescript
|
|
import { useRef, useState, useTransition, useCallback } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { chatApi } from "../api/chat";
|
|
|
|
export function useStreamingChat(conversationId: string | null) {
|
|
const [streamingContent, setStreamingContent] = useState<string>("");
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const queryClient = useQueryClient();
|
|
const [, startTransition] = useTransition();
|
|
|
|
const startStream = useCallback(
|
|
(userMessage: string, agentId?: string) => {
|
|
if (!conversationId) return;
|
|
|
|
setIsStreaming(true);
|
|
setStreamingContent("");
|
|
|
|
const abort = new AbortController();
|
|
abortRef.current = abort;
|
|
|
|
chatApi.postMessageAndStream(
|
|
conversationId,
|
|
{ content: userMessage, agentId },
|
|
{
|
|
onToken: (token: string) => {
|
|
startTransition(() => {
|
|
setStreamingContent((prev) => prev + token);
|
|
});
|
|
},
|
|
onDone: (messageId: string, content: string) => {
|
|
setIsStreaming(false);
|
|
setStreamingContent("");
|
|
abortRef.current = null;
|
|
// Optimistically insert the completed message into cache to avoid flash (Pitfall 2)
|
|
queryClient.setQueryData(
|
|
["chat", "messages", conversationId],
|
|
(old: unknown) => old, // Keep existing data -- invalidation will refetch
|
|
);
|
|
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
|
|
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
|
|
},
|
|
onError: (error: string) => {
|
|
setIsStreaming(false);
|
|
abortRef.current = null;
|
|
console.error("[useStreamingChat] Stream error:", error);
|
|
},
|
|
},
|
|
abort.signal,
|
|
);
|
|
},
|
|
[conversationId, queryClient, startTransition],
|
|
);
|
|
|
|
const stop = useCallback(() => {
|
|
abortRef.current?.abort();
|
|
abortRef.current = null;
|
|
const partial = streamingContent;
|
|
setIsStreaming(false);
|
|
setStreamingContent("");
|
|
// Persist partial content with [stopped] suffix (Open Question 3)
|
|
if (conversationId && partial.trim()) {
|
|
chatApi.savePartialMessage(conversationId, {
|
|
role: "assistant",
|
|
content: partial.trim() + " [stopped]",
|
|
}).then(() => {
|
|
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
|
|
});
|
|
}
|
|
}, [conversationId, streamingContent, queryClient]);
|
|
|
|
return { streamingContent, isStreaming, startStream, stop };
|
|
}
|
|
```
|
|
|
|
Key design decisions:
|
|
- `startTransition` wraps `setStreamingContent` so token appends don't block user input (PERF-02)
|
|
- `AbortController` for stop (CHAT-12) -- server detects `req.on("close")`
|
|
- On stop, partial content saved with " [stopped]" suffix to DB
|
|
- On done, cache invalidated (not optimistically set) to let React Query refetch the canonical data
|
|
|
|
**3. Replace Wave 0 test stubs in `ui/src/hooks/useStreamingChat.test.ts` with real unit tests:**
|
|
|
|
Replace the entire file. The hook's core logic (token accumulation, lifecycle, stop) can be tested by mocking `chatApi.postMessageAndStream` and `@tanstack/react-query`'s `useQueryClient`. Use `renderHook` from `@testing-library/react` (already installed) with a `QueryClientProvider` wrapper:
|
|
|
|
```typescript
|
|
// @vitest-environment jsdom
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { createElement } from "react";
|
|
import { useStreamingChat } from "./useStreamingChat";
|
|
import { chatApi } from "../api/chat";
|
|
|
|
// Mock chatApi
|
|
vi.mock("../api/chat", () => ({
|
|
chatApi: {
|
|
postMessageAndStream: vi.fn(),
|
|
savePartialMessage: vi.fn().mockResolvedValue({}),
|
|
postMessage: vi.fn().mockResolvedValue({}),
|
|
},
|
|
}));
|
|
|
|
function createWrapper() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
return ({ children }: { children: React.ReactNode }) =>
|
|
createElement(QueryClientProvider, { client: queryClient }, children);
|
|
}
|
|
|
|
describe("useStreamingChat", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("accumulates tokens from onToken callbacks into streamingContent", async () => {
|
|
// Mock postMessageAndStream to capture callbacks and simulate tokens
|
|
let capturedCallbacks: any;
|
|
vi.mocked(chatApi.postMessageAndStream).mockImplementation(
|
|
(_convId, _data, callbacks, _signal) => {
|
|
capturedCallbacks = callbacks;
|
|
return Promise.resolve();
|
|
},
|
|
);
|
|
|
|
const { result } = renderHook(
|
|
() => useStreamingChat("conv-1"),
|
|
{ wrapper: createWrapper() },
|
|
);
|
|
|
|
// Start stream
|
|
act(() => {
|
|
result.current.startStream("hello world");
|
|
});
|
|
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(result.current.streamingContent).toBe("");
|
|
|
|
// Simulate tokens arriving
|
|
act(() => {
|
|
capturedCallbacks.onToken("Hello ");
|
|
});
|
|
expect(result.current.streamingContent).toBe("Hello ");
|
|
|
|
act(() => {
|
|
capturedCallbacks.onToken("world!");
|
|
});
|
|
expect(result.current.streamingContent).toBe("Hello world!");
|
|
});
|
|
|
|
it("sets isStreaming=true when stream starts, false on done", async () => {
|
|
let capturedCallbacks: any;
|
|
vi.mocked(chatApi.postMessageAndStream).mockImplementation(
|
|
(_convId, _data, callbacks, _signal) => {
|
|
capturedCallbacks = callbacks;
|
|
return Promise.resolve();
|
|
},
|
|
);
|
|
|
|
const { result } = renderHook(
|
|
() => useStreamingChat("conv-1"),
|
|
{ wrapper: createWrapper() },
|
|
);
|
|
|
|
expect(result.current.isStreaming).toBe(false);
|
|
|
|
act(() => {
|
|
result.current.startStream("test");
|
|
});
|
|
expect(result.current.isStreaming).toBe(true);
|
|
|
|
act(() => {
|
|
capturedCallbacks.onDone("msg-1", "test response");
|
|
});
|
|
expect(result.current.isStreaming).toBe(false);
|
|
expect(result.current.streamingContent).toBe("");
|
|
});
|
|
|
|
it("stop() aborts the controller and sets isStreaming=false", async () => {
|
|
let capturedSignal: AbortSignal | undefined;
|
|
vi.mocked(chatApi.postMessageAndStream).mockImplementation(
|
|
(_convId, _data, _callbacks, signal) => {
|
|
capturedSignal = signal;
|
|
return Promise.resolve();
|
|
},
|
|
);
|
|
|
|
const { result } = renderHook(
|
|
() => useStreamingChat("conv-1"),
|
|
{ wrapper: createWrapper() },
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startStream("test");
|
|
});
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(capturedSignal?.aborted).toBe(false);
|
|
|
|
act(() => {
|
|
result.current.stop();
|
|
});
|
|
expect(result.current.isStreaming).toBe(false);
|
|
expect(capturedSignal?.aborted).toBe(true);
|
|
});
|
|
|
|
it("handles SSE error by setting isStreaming=false", async () => {
|
|
let capturedCallbacks: any;
|
|
vi.mocked(chatApi.postMessageAndStream).mockImplementation(
|
|
(_convId, _data, callbacks, _signal) => {
|
|
capturedCallbacks = callbacks;
|
|
return Promise.resolve();
|
|
},
|
|
);
|
|
|
|
const { result } = renderHook(
|
|
() => useStreamingChat("conv-1"),
|
|
{ wrapper: createWrapper() },
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startStream("test");
|
|
});
|
|
expect(result.current.isStreaming).toBe(true);
|
|
|
|
act(() => {
|
|
capturedCallbacks.onError("Stream error");
|
|
});
|
|
expect(result.current.isStreaming).toBe(false);
|
|
});
|
|
|
|
it("does nothing when conversationId is null", () => {
|
|
const { result } = renderHook(
|
|
() => useStreamingChat(null),
|
|
{ wrapper: createWrapper() },
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startStream("test");
|
|
});
|
|
expect(chatApi.postMessageAndStream).not.toHaveBeenCalled();
|
|
expect(result.current.isStreaming).toBe(false);
|
|
});
|
|
});
|
|
```
|
|
|
|
IMPORTANT: This replaces ALL `it.todo()` stubs with real tests. After this task runs, `useStreamingChat.test.ts` must have zero `it.todo()` entries.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui vitest run src/hooks/useStreamingChat.test.ts --reporter=verbose 2>&1 | tail -30</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- grep -q "useStreamingChat" ui/src/hooks/useStreamingChat.ts
|
|
- grep -q "postMessageAndStream" ui/src/api/chat.ts
|
|
- grep -q "startTransition" ui/src/hooks/useStreamingChat.ts
|
|
- grep -q "AbortController" ui/src/hooks/useStreamingChat.ts
|
|
- grep -q "\\[stopped\\]" ui/src/hooks/useStreamingChat.ts
|
|
- grep -q "savePartialMessage" ui/src/api/chat.ts
|
|
- grep -q "ReadableStream\\|getReader" ui/src/api/chat.ts
|
|
- NOT grep -q "it.todo" ui/src/hooks/useStreamingChat.test.ts (all stubs replaced with real tests)
|
|
- vitest reports 5 passing tests in useStreamingChat.test.ts
|
|
</acceptance_criteria>
|
|
<done>
|
|
- useStreamingChat hook exists with startStream, stop, streamingContent, isStreaming
|
|
- chatApi.postMessageAndStream uses fetch ReadableStream for POST SSE
|
|
- chatApi.savePartialMessage persists partial content on stop
|
|
- startTransition used for token accumulation (PERF-02)
|
|
- AbortController used for stop functionality (CHAT-12)
|
|
- Partial message saved with " [stopped]" suffix on stop
|
|
- Wave 0 test stubs REPLACED with 5 real unit tests covering: token accumulation, isStreaming lifecycle, stop() abort, error handling, null conversationId guard
|
|
- All tests pass (no it.todo remaining)
|
|
- UI TypeScript compiles clean
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
|
|
- `pnpm --filter @paperclipai/server exec -- tsc --noEmit` passes (or pre-existing non-chat errors only)
|
|
- `pnpm --filter @paperclipai/ui vitest run src/hooks/useStreamingChat.test.ts` passes with 5 real tests (zero todos)
|
|
- Server routes include stream, edit, truncate endpoints
|
|
- Client hook manages full SSE lifecycle
|
|
- PERF-02 verified: flushHeaders position precedes for-await in chat.ts
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Tokens stream from server to client via SSE (CHAT-01)
|
|
- Stop generation aborts the connection and saves partial content (CHAT-12)
|
|
- SSE headers flushed before generation begins (PERF-02)
|
|
- Edit and truncate server endpoints ready for Plan 03 UI
|
|
- useStreamingChat has real passing unit tests (not stubs)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/22-agent-streaming/22-01-SUMMARY.md`
|
|
</output>
|