FN-6496: preserve chat history during streaming attach
Keep existing chat thread messages visible while reconnecting to streamed assistant responses. - Hydrate or reload prior session messages before attaching an in-flight stream in full chat. - Add QuickChat session-specific message loading so resumed streams do not hide earlier turns. - Cover full chat and QuickChat streaming attach behavior with regression tests. - Add the required patch changeset and quarantine unrelated flaky dashboard tests observed during verification. Files changed: .changeset/fn-6496-chat-stream-prior-thread.md | 5 + .../dashboard/app/hooks/__tests__/useChat.test.ts | 286 ++++++++++++++++++++- .../app/hooks/__tests__/useQuickChat.test.ts | 147 ++++++++++- packages/dashboard/app/hooks/useChat.ts | 17 +- packages/dashboard/app/hooks/useQuickChat.ts | 62 ++--- packages/dashboard/vitest.config.ts | 13 +- scripts/lib/test-quarantine.json | 13 +- 7 files changed, 502 insertions(+), 41 deletions(-) Fusion-Task-Id: FN-6496 Fusion-Task-Lineage: eb371b39-9810-48b7-a395-30066c9bc3be
This commit is contained in:
5
.changeset/fn-6496-chat-stream-prior-thread.md
Normal file
5
.changeset/fn-6496-chat-stream-prior-thread.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Keep prior chat thread messages visible while reconnecting to an in-flight streamed assistant response.
|
||||
@@ -1225,8 +1225,14 @@ describe("useChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("re-attaches from fetchChatSession replay id when tab becomes visible", async () => {
|
||||
it("FN-6496 loads prior thread when visibility resume reattaches", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
const priorThreadNewestFirst = [
|
||||
makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }),
|
||||
makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }),
|
||||
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
||||
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
||||
];
|
||||
const generatingSession = {
|
||||
...session,
|
||||
isGenerating: true,
|
||||
@@ -1241,6 +1247,9 @@ describe("useChat", () => {
|
||||
};
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: [] })
|
||||
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
||||
const addToast = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useChat(undefined, addToast));
|
||||
@@ -1270,6 +1279,13 @@ describe("useChat", () => {
|
||||
undefined,
|
||||
{ lastEventId: 77 },
|
||||
);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"msg-001",
|
||||
"msg-002",
|
||||
"msg-003",
|
||||
"msg-004",
|
||||
]);
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1292,10 +1308,18 @@ describe("useChat", () => {
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
const priorThreadNewestFirst = [
|
||||
makeMessage({ id: "msg-004", sessionId: staleSession.id, role: "assistant", content: "Second answer" }),
|
||||
makeMessage({ id: "msg-003", sessionId: staleSession.id, role: "user", content: "Second question" }),
|
||||
makeMessage({ id: "msg-002", sessionId: staleSession.id, role: "assistant", content: "First answer" }),
|
||||
makeMessage({ id: "msg-001", sessionId: staleSession.id, role: "user", content: "First question" }),
|
||||
];
|
||||
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [staleSession] });
|
||||
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: [] })
|
||||
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
@@ -1318,6 +1342,12 @@ describe("useChat", () => {
|
||||
expect(result.current.streamingText).toBe("partial text");
|
||||
expect(result.current.streamingThinking).toBe("thinking");
|
||||
expect(result.current.streamingToolCalls).toHaveLength(1);
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"msg-001",
|
||||
"msg-002",
|
||||
"msg-003",
|
||||
"msg-004",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2835,6 +2865,258 @@ describe("useChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("FN-6496 loads prior thread during chat:session:updated in-flight attach", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Existing" });
|
||||
const priorThreadNewestFirst = [
|
||||
makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }),
|
||||
makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }),
|
||||
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
||||
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
||||
];
|
||||
const generatingSession = {
|
||||
...session,
|
||||
isGenerating: true,
|
||||
inFlightGeneration: {
|
||||
status: "generating" as const,
|
||||
streamingText: "live partial",
|
||||
streamingThinking: "thinking",
|
||||
toolCalls: [],
|
||||
replayFromEventId: 88,
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: [] })
|
||||
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
||||
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession(session.id);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession?.id).toBe(session.id);
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith(session.id, { limit: 50, order: "desc" }, "proj-123");
|
||||
});
|
||||
expect(result.current.messages).toEqual([]);
|
||||
|
||||
act(() => {
|
||||
subscribeHandler["chat:session:updated"]?.({
|
||||
data: JSON.stringify(generatingSession),
|
||||
} as MessageEvent);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.streamingText).toBe("live partial");
|
||||
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
||||
session.id,
|
||||
expect.any(Object),
|
||||
"proj-123",
|
||||
{ lastEventId: 88 },
|
||||
);
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"msg-001",
|
||||
"msg-002",
|
||||
"msg-003",
|
||||
"msg-004",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("FN-6496 loads prior thread when auto-reattach effect observes refreshed generation", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Stale" });
|
||||
const priorThreadNewestFirst = [
|
||||
makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }),
|
||||
makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }),
|
||||
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
||||
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
||||
];
|
||||
const generatingSession = {
|
||||
...session,
|
||||
isGenerating: true,
|
||||
inFlightGeneration: {
|
||||
status: "generating" as const,
|
||||
streamingText: "refreshed partial",
|
||||
streamingThinking: "thinking",
|
||||
toolCalls: [],
|
||||
replayFromEventId: 90,
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: [] })
|
||||
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
||||
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession(session.id);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.streamingText).toBe("refreshed partial");
|
||||
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
||||
session.id,
|
||||
expect.any(Object),
|
||||
"proj-123",
|
||||
{ lastEventId: 90 },
|
||||
);
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"msg-001",
|
||||
"msg-002",
|
||||
"msg-003",
|
||||
"msg-004",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("FN-6496 loads prior thread when reconnectSessionSilently reattaches after send suspension", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Reconnect" });
|
||||
const priorThreadNewestFirst = [
|
||||
makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }),
|
||||
makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }),
|
||||
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
||||
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
||||
];
|
||||
const generatingSession = {
|
||||
...session,
|
||||
isGenerating: true,
|
||||
inFlightGeneration: {
|
||||
status: "generating" as const,
|
||||
streamingText: "reconnected partial",
|
||||
streamingThinking: "thinking",
|
||||
toolCalls: [],
|
||||
replayFromEventId: 91,
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
let onError: ((data: string | apiModule.ChatFailureInfo, tempUserMessageId: string) => void) | undefined;
|
||||
|
||||
mockFetchChatSessions
|
||||
.mockResolvedValueOnce({ sessions: [session] })
|
||||
.mockResolvedValueOnce({ sessions: [generatingSession] });
|
||||
mockFetchChatSession
|
||||
.mockResolvedValueOnce({ session })
|
||||
.mockResolvedValueOnce({ session: generatingSession });
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: [] })
|
||||
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||
onError = handlers.onError;
|
||||
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.id);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession?.id).toBe(session.id);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendMessage("Continue");
|
||||
onError?.("Failed to fetch", "temp-reconnect");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.streamingText).toBe("reconnected partial");
|
||||
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
||||
session.id,
|
||||
expect.any(Object),
|
||||
"proj-123",
|
||||
{ lastEventId: 91 },
|
||||
);
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"msg-001",
|
||||
"msg-002",
|
||||
"msg-003",
|
||||
"msg-004",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("FN-6496 does not refetch or duplicate when prior thread is already loaded", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Loaded" });
|
||||
const priorThreadNewestFirst = [
|
||||
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
||||
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
||||
];
|
||||
const generatingSession = {
|
||||
...session,
|
||||
isGenerating: true,
|
||||
inFlightGeneration: {
|
||||
status: "generating" as const,
|
||||
streamingText: "live partial",
|
||||
streamingThinking: "",
|
||||
toolCalls: [],
|
||||
replayFromEventId: 89,
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
||||
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession(session.id);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual(["msg-001", "msg-002"]);
|
||||
});
|
||||
mockFetchChatMessages.mockClear();
|
||||
|
||||
act(() => {
|
||||
subscribeHandler["chat:session:updated"]?.({
|
||||
data: JSON.stringify(generatingSession),
|
||||
} as MessageEvent);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
||||
session.id,
|
||||
expect.any(Object),
|
||||
"proj-123",
|
||||
{ lastEventId: 89 },
|
||||
);
|
||||
});
|
||||
expect(mockFetchChatMessages).not.toHaveBeenCalled();
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual(["msg-001", "msg-002"]);
|
||||
});
|
||||
|
||||
it("FN-5104 ignores replay checkpoint bumps while attach stream is already active", async () => {
|
||||
const generating = {
|
||||
...makeSession({ id: "session-001", agentId: "agent-001", title: "Gen" }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, fireEvent, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatSession } from "@fusion/core";
|
||||
import type { ChatMessage, ChatSession } from "@fusion/core";
|
||||
import * as apiModule from "../../api";
|
||||
import { getChatPendingMessageKey } from "../chatPendingMessageStorage";
|
||||
import { getPersistedLastQuickChatSessionId } from "../quickChatLastSessionStorage";
|
||||
@@ -40,6 +40,18 @@ function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" |
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(overrides: Partial<ChatMessage> & Pick<ChatMessage, "id" | "sessionId" | "role" | "content">): ChatMessage {
|
||||
return {
|
||||
id: overrides.id,
|
||||
sessionId: overrides.sessionId,
|
||||
role: overrides.role,
|
||||
content: overrides.content,
|
||||
thinkingOutput: overrides.thinkingOutput ?? null,
|
||||
metadata: overrides.metadata ?? null,
|
||||
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
const setDocumentVisibilityState = (state: DocumentVisibilityState) => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
@@ -1712,8 +1724,14 @@ describe("useQuickChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reattaches with replayFromEventId when tab becomes visible and server is generating", async () => {
|
||||
it("FN-6496 loads prior thread when QuickChat visibility resume reattaches", async () => {
|
||||
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
|
||||
const priorThreadNewestFirst = [
|
||||
makeMessage({ id: "msg-004", sessionId: existingSession.id, role: "assistant", content: "Second answer" }),
|
||||
makeMessage({ id: "msg-003", sessionId: existingSession.id, role: "user", content: "Second question" }),
|
||||
makeMessage({ id: "msg-002", sessionId: existingSession.id, role: "assistant", content: "First answer" }),
|
||||
makeMessage({ id: "msg-001", sessionId: existingSession.id, role: "user", content: "First question" }),
|
||||
];
|
||||
const generatingSession = {
|
||||
...existingSession,
|
||||
isGenerating: true,
|
||||
@@ -1730,7 +1748,9 @@ describe("useQuickChat", () => {
|
||||
|
||||
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
|
||||
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: [] })
|
||||
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
|
||||
|
||||
@@ -1750,6 +1770,13 @@ describe("useQuickChat", () => {
|
||||
"proj-123",
|
||||
{ lastEventId: 17 },
|
||||
);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"msg-001",
|
||||
"msg-002",
|
||||
"msg-003",
|
||||
"msg-004",
|
||||
]);
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2153,10 +2180,16 @@ describe("useQuickChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sets isStreaming=true when initializing a session with isGenerating=true", async () => {
|
||||
it("FN-6496 loads prior thread when initializing a generating QuickChat session", async () => {
|
||||
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||
const priorThreadNewestFirst = [
|
||||
makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }),
|
||||
makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }),
|
||||
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
||||
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
||||
];
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: priorThreadNewestFirst });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
@@ -2167,9 +2200,113 @@ describe("useQuickChat", () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.streamingText).toBe("");
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"msg-001",
|
||||
"msg-002",
|
||||
"msg-003",
|
||||
"msg-004",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("FN-6496 loads prior thread when QuickChat auto-reattach effect observes refreshed generation", async () => {
|
||||
const staleSession = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false };
|
||||
const generatingSession = {
|
||||
...staleSession,
|
||||
isGenerating: true,
|
||||
inFlightGeneration: {
|
||||
status: "generating" as const,
|
||||
streamingText: "refreshed partial",
|
||||
streamingThinking: "thinking",
|
||||
toolCalls: [],
|
||||
replayFromEventId: 18,
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
const priorThreadNewestFirst = [
|
||||
makeMessage({ id: "msg-004", sessionId: staleSession.id, role: "assistant", content: "Second answer" }),
|
||||
makeMessage({ id: "msg-003", sessionId: staleSession.id, role: "user", content: "Second question" }),
|
||||
makeMessage({ id: "msg-002", sessionId: staleSession.id, role: "assistant", content: "First answer" }),
|
||||
makeMessage({ id: "msg-001", sessionId: staleSession.id, role: "user", content: "First question" }),
|
||||
];
|
||||
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: [] })
|
||||
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.selectSession(staleSession);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.streamingText).toBe("refreshed partial");
|
||||
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
||||
"session-001",
|
||||
expect.any(Object),
|
||||
"proj-123",
|
||||
{ lastEventId: 18 },
|
||||
);
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"msg-001",
|
||||
"msg-002",
|
||||
"msg-003",
|
||||
"msg-004",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("FN-6496 does not refetch or duplicate QuickChat thread when already loaded", async () => {
|
||||
const session = {
|
||||
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
||||
isGenerating: true,
|
||||
inFlightGeneration: {
|
||||
status: "generating" as const,
|
||||
streamingText: "live partial",
|
||||
streamingThinking: "",
|
||||
toolCalls: [],
|
||||
replayFromEventId: 19,
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
const priorThreadNewestFirst = [
|
||||
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
||||
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
||||
];
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: priorThreadNewestFirst });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual(["msg-001", "msg-002"]);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
mockFetchChatMessages.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession(session);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
||||
"session-001",
|
||||
expect.any(Object),
|
||||
"proj-123",
|
||||
{ lastEventId: 19 },
|
||||
);
|
||||
});
|
||||
expect(mockFetchChatMessages).not.toHaveBeenCalled();
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual(["msg-001", "msg-002"]);
|
||||
});
|
||||
|
||||
it("does not set isStreaming when isGenerating is false", async () => {
|
||||
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false };
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||
|
||||
@@ -527,13 +527,24 @@ export function useChat(
|
||||
const attachIfGenerating = useCallback((
|
||||
sessionId: string,
|
||||
inFlightGeneration?: ChatInFlightGenerationState | null,
|
||||
options?: { silent?: boolean },
|
||||
options?: { silent?: boolean; priorThreadLoadAlreadyStarted?: boolean },
|
||||
) => {
|
||||
if (streamRef.current || !sessionId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelledByUserRef.current = false;
|
||||
const currentMessages = messagesRef.current;
|
||||
const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId;
|
||||
if (needsPriorThreadLoad && !options?.priorThreadLoadAlreadyStarted) {
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-06-16-18:10:
|
||||
In-flight attach must keep the persisted prior thread visible while the assistant bubble streams.
|
||||
The chat:message:added SSE echo is suppressed during streaming to avoid duplicate local bubbles, so attach has to hydrate cached history and start a thread load itself when messages are empty or from another session.
|
||||
*/
|
||||
hydrateMessagesFromCache(sessionId);
|
||||
void loadMessages(sessionId);
|
||||
}
|
||||
if (inFlightGeneration) {
|
||||
setStreamingText(inFlightGeneration.streamingText);
|
||||
setStreamingThinking(inFlightGeneration.streamingThinking);
|
||||
@@ -605,7 +616,7 @@ export function useChat(
|
||||
: null,
|
||||
};
|
||||
return true;
|
||||
}, [addToast, loadMessages, projectId, flushPendingMessage]);
|
||||
}, [addToast, hydrateMessagesFromCache, loadMessages, projectId, flushPendingMessage]);
|
||||
|
||||
// Select a session
|
||||
const selectSession = useCallback(
|
||||
@@ -663,7 +674,7 @@ export function useChat(
|
||||
// all streaming state. Showing "Connecting…" immediately tells the
|
||||
// user the AI is still working.
|
||||
if (session?.isGenerating) {
|
||||
attachIfGenerating(session.id, session.inFlightGeneration);
|
||||
attachIfGenerating(session.id, session.inFlightGeneration, { priorThreadLoadAlreadyStarted: true });
|
||||
}
|
||||
|
||||
// Persist active session to localStorage
|
||||
|
||||
@@ -241,6 +241,8 @@ export function useQuickChat(
|
||||
// component's useEffect that depends on switchSession.
|
||||
const activeSessionRef = useRef<EnrichedChatSession | null>(activeSession);
|
||||
activeSessionRef.current = activeSession;
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
// Max retries for session init to prevent infinite toast loops
|
||||
const initRetryCountRef = useRef(0);
|
||||
@@ -321,6 +323,20 @@ export function useQuickChat(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMessagesForSession = useCallback(async (sessionId: string) => {
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId);
|
||||
if (activeSessionRef.current?.id === sessionId) {
|
||||
setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useQuickChat] Failed to load messages:", err);
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const attachIfGenerating = useCallback((
|
||||
sessionId: string,
|
||||
inFlightGeneration?: ChatInFlightGenerationState | null,
|
||||
@@ -331,6 +347,16 @@ export function useQuickChat(
|
||||
}
|
||||
|
||||
cancelledByUserRef.current = false;
|
||||
const currentMessages = messagesRef.current;
|
||||
const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId;
|
||||
if (needsPriorThreadLoad) {
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-06-16-18:16:
|
||||
QuickChat has the same streaming visibility contract as the full chat view: a resumed in-flight assistant bubble must not hide prior user turns or assistant responses.
|
||||
Because QuickChat has no message cache and streaming suppresses persisted echo handling, attach fetches the session thread directly by id instead of relying on activeSession-bound loaders that may see stale state.
|
||||
*/
|
||||
void loadMessagesForSession(sessionId);
|
||||
}
|
||||
if (inFlightGeneration) {
|
||||
setStreamingText(inFlightGeneration.streamingText);
|
||||
setStreamingThinking(inFlightGeneration.streamingThinking);
|
||||
@@ -361,9 +387,7 @@ export function useQuickChat(
|
||||
isStreamingRef.current = false;
|
||||
streamRef.current = null;
|
||||
lastAttachedGenerationRef.current = null;
|
||||
void fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId).then((data) => {
|
||||
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
|
||||
}).catch(() => {});
|
||||
void loadMessagesForSession(sessionId);
|
||||
flushPendingMessage();
|
||||
},
|
||||
onError: (data) => {
|
||||
@@ -378,9 +402,7 @@ export function useQuickChat(
|
||||
if (!options?.silent) {
|
||||
addToast?.(errorMessage, "error");
|
||||
}
|
||||
void fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId).then((data) => {
|
||||
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
|
||||
}).catch(() => {});
|
||||
void loadMessagesForSession(sessionId);
|
||||
flushPendingMessage();
|
||||
},
|
||||
});
|
||||
@@ -397,7 +419,7 @@ export function useQuickChat(
|
||||
: null,
|
||||
};
|
||||
return true;
|
||||
}, [addToast, projectId, flushPendingMessage]);
|
||||
}, [addToast, loadMessagesForSession, flushPendingMessage, t, projectId]);
|
||||
|
||||
// Fetch existing sessions and find/create one for the given target
|
||||
const initializeSession = useCallback(
|
||||
@@ -456,17 +478,8 @@ export function useQuickChat(
|
||||
const loadMessages = useCallback(async () => {
|
||||
if (!activeSession) return;
|
||||
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const sessionId = activeSession.id;
|
||||
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId);
|
||||
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
|
||||
} catch (err) {
|
||||
console.error("[useQuickChat] Failed to load messages:", err);
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}, [activeSession, projectId]);
|
||||
await loadMessagesForSession(activeSession.id);
|
||||
}, [activeSession, loadMessagesForSession]);
|
||||
|
||||
// Load messages when session changes
|
||||
useEffect(() => {
|
||||
@@ -524,17 +537,8 @@ export function useQuickChat(
|
||||
// Reload messages from server (for same-session revisit)
|
||||
const reloadMessages = useCallback(async () => {
|
||||
if (!activeSession) return;
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const sessionId = activeSession.id;
|
||||
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId);
|
||||
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
|
||||
} catch (err) {
|
||||
console.error("[useQuickChat] Failed to reload messages:", err);
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}, [activeSession, projectId]);
|
||||
await loadMessagesForSession(activeSession.id);
|
||||
}, [activeSession, loadMessagesForSession]);
|
||||
|
||||
const resetTransientComposerState = useCallback(() => {
|
||||
cancelStreamingFlushesRef.current?.();
|
||||
|
||||
@@ -235,8 +235,19 @@ const qualityAppSettingsOnlyTests = ["app/components/__tests__/SettingsModal.tes
|
||||
FNXC:DashboardTestQuarantine 2026-06-14-17:01:
|
||||
FN-6454 applied the quarantine deletion ratchet to every dashboard test quarantined on 2026-06-14.
|
||||
Keep this list empty until a new flaky dashboard test is quarantined with a matching ledger entry.
|
||||
|
||||
FNXC:DashboardTestQuarantine 2026-06-16-18:59:
|
||||
FN-6496 verification observed QuickEntryBox expanded-mode assertions fail only in the workspace gate while an isolated file rerun passed.
|
||||
Quarantine the file under the deletion ratchet instead of appeasing timing/state leakage with retries or widened waits.
|
||||
|
||||
FNXC:DashboardTestQuarantine 2026-06-16-19:21:
|
||||
FN-6496 merge verification observed github-tracking-hook fail during the changed-test backfill shard with temp-directory cleanup ENOTEMPTY, then pass on isolated rerun.
|
||||
Quarantine the cleanup-flaky file under the deletion ratchet rather than changing production or test timing outside the chat-streaming scope.
|
||||
*/
|
||||
const quarantinedDashboardTests: string[] = [];
|
||||
const quarantinedDashboardTests: string[] = [
|
||||
"app/components/__tests__/QuickEntryBox.test.tsx",
|
||||
"src/__tests__/github-tracking-hook.test.ts",
|
||||
];
|
||||
|
||||
const qualityApiTests = [
|
||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
{
|
||||
"$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.",
|
||||
"entries": []
|
||||
"entries": [
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx",
|
||||
"reason": "FN-6496 verification: pnpm test failed in QuickEntryBox expanded-mode tests (expected aria-expanded=false, received true) while FN-6496 only changed chat streaming hooks; direct isolated rerun of this file passed, so classify as unrelated flaky state leakage. Failing command: pnpm test; confirming command: pnpm --filter @fusion/dashboard exec vitest run app/components/__tests__/QuickEntryBox.test.tsx --reporter=dot --silent=passed-only.",
|
||||
"quarantinedAt": "2026-06-16"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/src/__tests__/github-tracking-hook.test.ts",
|
||||
"reason": "FN-6496 merge verification: pnpm test failed in dashboard-api-quality-backfill with ENOTEMPTY while removing a temp task directory; isolated rerun of the file passed, so classify as unrelated cleanup flake. Failing command: pnpm test; confirming command: pnpm --filter @fusion/dashboard exec vitest run src/__tests__/github-tracking-hook.test.ts --reporter=dot --silent=passed-only.",
|
||||
"quarantinedAt": "2026-06-16"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user