feat(FN-3609): split TaskDetailModal tests into focused suites

This merge splits the monolithic TaskDetailModal test file into six focused test suites (rendering, attachments/tabs, definition/actions, inline-editing/integrations, models/progress/workflow, responsive/dependencies), adds a changeset for the test isolation baseline, fixes chat SSE optimistic echo

Fusion-Task-Id: FN-3609
This commit is contained in:
Fusion
2026-05-06 16:00:50 -07:00
committed by gsxdsm
parent 2fce7b36cf
commit 2140148fe2
6 changed files with 163 additions and 14 deletions

View File

@@ -51,6 +51,23 @@ describe("streamChatResponse SSE parser", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("flushes terminal done event when stream ends without final newline", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"msg-tail\"}"]), { status: 200 }),
);
const donePayloads: Array<{ messageId: string }> = [];
streamChatResponse("s-1", "hi", {
onDone: (data) => donePayloads.push(data),
onError: vi.fn(),
});
await vi.waitFor(() => {
expect(donePayloads).toEqual([{ messageId: "msg-tail" }]);
});
});
it("parses done payload assistant snapshots when present", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
@@ -70,11 +87,25 @@ describe("streamChatResponse SSE parser", () => {
});
await vi.waitFor(() => {
expect(donePayloads).toEqual([{ messageId: "msg-1", message: { id: "msg-1", sessionId: "s-1", role: "assistant", content: "Final reply", thinkingOutput: null, metadata: null, createdAt: "2026-01-01T00:00:00.000Z" } }]);
expect(donePayloads).toEqual([
{
messageId: "msg-1",
message: {
id: "msg-1",
sessionId: "s-1",
role: "assistant",
content: "Final reply",
thinkingOutput: null,
metadata: null,
createdAt: "2026-01-01T00:00:00.000Z",
},
},
]);
});
});
it("handles done events that have no data payload", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue(
it("handles done events that have no data payload", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(createChunkedStream(["event: done\n\n"]), { status: 200 }),
);

View File

@@ -1354,11 +1354,8 @@ describe("useChat", () => {
});
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
// Track stream handlers separately from SSE handlers
let streamDoneHandler: ((data: { messageId: string }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
// Capture the onDone handler for stream completion
streamDoneHandler = handlers.onDone;
void handlers.onDone;
return { close: vi.fn(), isConnected: () => true };
});
@@ -1376,7 +1373,6 @@ describe("useChat", () => {
expect(result.current.messages).toHaveLength(0);
});
// Start streaming
await act(async () => {
await result.current.sendMessage("Hello!");
});
@@ -1385,8 +1381,6 @@ describe("useChat", () => {
expect(result.current.isStreaming).toBe(true);
});
// Simulate SSE event - should not add message during streaming
// because isStreaming is true
const newMessage = makeMessage({ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi" });
act(() => {
subscribeHandler["chat:message:added"]?.({
@@ -1394,10 +1388,69 @@ describe("useChat", () => {
} as MessageEvent);
});
// Message should not be added during streaming
// (the SSE handler checks isStreaming and skips adding)
await waitFor(() => {
expect(result.current.messages).toHaveLength(1); // Only the optimistic user message
expect(result.current.messages).toHaveLength(1);
});
});
it("dedupes optimistic user message when persisted user echo arrives after done", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
});
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
let doneHandler: ((data: { messageId: string }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
doneHandler = handlers.onDone;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
act(() => {
result.current.sendMessage("Hello!");
});
await waitFor(() => {
expect(result.current.messages.filter((message) => message.role === "user")).toHaveLength(1);
});
act(() => {
doneHandler?.({ messageId: "msg-assistant-001" });
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages).toHaveLength(2);
});
const persistedEcho = makeMessage({
id: "msg-user-001",
sessionId: "session-001",
role: "user",
content: "Hello!",
});
act(() => {
subscribeHandler["chat:message:added"]?.({
data: JSON.stringify(persistedEcho),
} as MessageEvent);
});
await waitFor(() => {
const userMessages = result.current.messages.filter((message) => message.role === "user");
expect(userMessages).toHaveLength(1);
});
});

View File

@@ -719,8 +719,24 @@ export function useChat(
// Use ref to get the current value (state may not be updated yet when handler runs)
if (activeSessionRef.current?.id === message.sessionId && !isStreamingRef.current) {
setMessages((prev) => {
// Avoid duplicates
// Avoid duplicates by persisted id first.
if (prev.some((m) => m.id === message.id)) return prev;
// Reconcile optimistic local user messages against persisted SSE echoes.
// The optimistic message uses a temp id and should be replaced instead of appended.
if (message.role === "user") {
const optimisticIndex = prev.findIndex((candidate) =>
candidate.role === "user"
&& candidate.id.startsWith("temp-")
&& candidate.content.trim() === message.content.trim(),
);
if (optimisticIndex >= 0) {
const next = [...prev];
next[optimisticIndex] = message;
return next;
}
}
return [...prev, message];
});
}

View File

@@ -1781,4 +1781,5 @@ describe("ChatManager generation isolation", () => {
// and not re-deleted/corrupted by sendOne's late finally.
expect(chatManager.isGenerating("chat-001")).toBe(false);
});
});

View File

@@ -1193,6 +1193,51 @@ describe("Chat API Routes", () => {
expect(output).toContain('"messageId":"msg-final"');
expect(output).toContain('"content":"Final reply"');
});
it("uses the same generation id for subscription and sendMessage", async () => {
mockGetSession.mockReturnValue(sampleSession);
const chatModule = await import("../chat.js");
vi.mocked(chatModule.checkRateLimit).mockReturnValue(true);
mockBeginGeneration.mockReturnValueOnce({
generationId: 42,
abortController: new AbortController(),
});
mockSendMessage.mockImplementation(async (sessionId: string) => {
mockChatStreamManager.broadcast(sessionId, {
type: "done",
data: { messageId: "msg-final" },
});
});
const req = createSSERequest();
const { res } = createSSEResponse();
req.body = { content: "Hello" };
req.params = { id: "chat-abc123" };
req.query = {} as any;
req.headers = {} as any;
req.ip = "127.0.0.1";
req.socket = { remoteAddress: "127.0.0.1" } as any;
await invokeSSEHandler(req, res, store, mockChatStore, mockChatManager);
expect(mockBeginGeneration).toHaveBeenCalledWith("chat-abc123");
expect(mockChatStreamManager.subscribe).toHaveBeenCalledWith(
"chat-abc123",
expect.any(Function),
{ generationId: 42 },
);
expect(mockSendMessage).toHaveBeenCalledWith(
"chat-abc123",
"Hello",
undefined,
undefined,
undefined,
{ generationId: 42 },
);
});
});
});