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:
gsxdsm
2026-06-16 19:25:32 -07:00
parent a15b4caace
commit 198fb17277
7 changed files with 502 additions and 41 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Keep prior chat thread messages visible while reconnecting to an in-flight streamed assistant response.

View File

@@ -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" }),

View File

@@ -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 });

View File

@@ -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

View File

@@ -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?.();

View File

@@ -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,

View File

@@ -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"
}
]
}