feat(FN-3336): recover chat isGenerating state on session load to prevent U
Merges FN-3336 to recover chat streaming state on page reload — adds `isGenerating()` and `getGeneratingSessionIds()` to `ChatManager`, enriches the session API to surface active-streaming sessions, and makes `useChat` and `useQuickChat` hooks restore the `isGenerating` flag when loading existing se Fusion-Task-Id: FN-3336
This commit is contained in:
5
.changeset/fix-chat-progress-indicator-reload.md
Normal file
5
.changeset/fix-chat-progress-indicator-reload.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix chat progress indicator on reload: show "Connecting…" indicator when dashboard reloads during active AI generation
|
||||||
@@ -63,6 +63,8 @@ export type EnrichedChatSession = ChatSession & {
|
|||||||
lastMessagePreview?: string;
|
lastMessagePreview?: string;
|
||||||
/** Timestamp of the last message in the session */
|
/** Timestamp of the last message in the session */
|
||||||
lastMessageAt?: string;
|
lastMessageAt?: string;
|
||||||
|
/** Whether a generation is currently in progress for this session */
|
||||||
|
isGenerating?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A parsed @ mention of an agent in a chat message */
|
/** A parsed @ mention of an agent in a chat message */
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import type {
|
|||||||
AgentRating,
|
AgentRating,
|
||||||
AgentRatingSummary,
|
AgentRatingSummary,
|
||||||
AgentRatingInput,
|
AgentRatingInput,
|
||||||
ChatSession,
|
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
EnrichedChatSession,
|
EnrichedChatSession,
|
||||||
Roadmap,
|
Roadmap,
|
||||||
@@ -7673,7 +7672,7 @@ export interface ChatSessionListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatSessionResponse {
|
export interface ChatSessionResponse {
|
||||||
session: ChatSession;
|
session: EnrichedChatSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessageListResponse {
|
export interface ChatMessageListResponse {
|
||||||
|
|||||||
@@ -1410,4 +1410,95 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FN-3336: streaming state recovery on reload", () => {
|
||||||
|
it("sets isStreaming=true when selecting a session with isGenerating=true", async () => {
|
||||||
|
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||||
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
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.isStreaming).toBe(true);
|
||||||
|
expect(result.current.streamingText).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set isStreaming when isGenerating is false", async () => {
|
||||||
|
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false };
|
||||||
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
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.isStreaming).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears recovery streaming state when SSE delivers assistant message", async () => {
|
||||||
|
let subscribeHandler: Record<string, (event: MessageEvent) => void> = {};
|
||||||
|
mockSubscribeSse.mockImplementation((_url, options) => {
|
||||||
|
if (options?.events) {
|
||||||
|
subscribeHandler = options.events as typeof subscribeHandler;
|
||||||
|
}
|
||||||
|
return () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||||
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
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.isStreaming).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate SSE delivering the completed assistant message
|
||||||
|
const assistantMessage = makeMessage({
|
||||||
|
id: "msg-assistant-001",
|
||||||
|
sessionId: "session-001",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Generated response",
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
subscribeHandler["chat:message:added"](
|
||||||
|
new MessageEvent("chat:message:added", { data: JSON.stringify(assistantMessage) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
expect(result.current.streamingText).toBe("");
|
||||||
|
expect(result.current.messages.some((m) => m.id === "msg-assistant-001")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FN_AGENT_ID, useQuickChat } from "../useQuickChat";
|
|||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchResumeChatSession: vi.fn(),
|
fetchResumeChatSession: vi.fn(),
|
||||||
fetchChatSessions: vi.fn(),
|
fetchChatSessions: vi.fn(),
|
||||||
|
fetchChatSession: vi.fn(),
|
||||||
createChatSession: vi.fn(),
|
createChatSession: vi.fn(),
|
||||||
fetchChatMessages: vi.fn(),
|
fetchChatMessages: vi.fn(),
|
||||||
streamChatResponse: vi.fn(),
|
streamChatResponse: vi.fn(),
|
||||||
@@ -15,6 +16,7 @@ vi.mock("../../api", () => ({
|
|||||||
|
|
||||||
const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession);
|
const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession);
|
||||||
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
||||||
|
const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession);
|
||||||
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
|
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
|
||||||
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
||||||
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
||||||
@@ -43,6 +45,9 @@ describe("useQuickChat", () => {
|
|||||||
session: makeSession({ id: "session-001", agentId: "agent-001" }),
|
session: makeSession({ id: "session-001", agentId: "agent-001" }),
|
||||||
});
|
});
|
||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
mockFetchChatSession.mockResolvedValue({
|
||||||
|
session: { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false },
|
||||||
|
});
|
||||||
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||||
mockCancelChatResponse.mockResolvedValue({ success: true });
|
mockCancelChatResponse.mockResolvedValue({ success: true });
|
||||||
});
|
});
|
||||||
@@ -703,4 +708,80 @@ describe("useQuickChat", () => {
|
|||||||
expect(addToast).toHaveBeenCalledWith("Failed to get response", "error");
|
expect(addToast).toHaveBeenCalledWith("Failed to get response", "error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FN-3336: streaming state recovery on reload", () => {
|
||||||
|
it("sets isStreaming=true when initializing a session with isGenerating=true", async () => {
|
||||||
|
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||||
|
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("agent-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
expect(result.current.streamingText).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set isStreaming when isGenerating is false", async () => {
|
||||||
|
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false };
|
||||||
|
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("agent-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears recovery streaming state when polling detects generation complete", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
|
||||||
|
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||||
|
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
// After first poll, server reports generation is done and has a new assistant message
|
||||||
|
mockFetchChatSession.mockResolvedValue({
|
||||||
|
session: { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false },
|
||||||
|
});
|
||||||
|
mockFetchChatMessages.mockResolvedValue({
|
||||||
|
messages: [
|
||||||
|
{ id: "msg-1", sessionId: "session-001", role: "assistant", content: "Done", thinkingOutput: null, metadata: null, createdAt: new Date().toISOString() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("agent-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance time to trigger the polling interval (3s)
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(3500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
expect(result.current.streamingText).toBe("");
|
||||||
|
expect(result.current.messages.some((m) => m.id === "msg-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface ChatSessionInfo {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastMessagePreview?: string;
|
lastMessagePreview?: string;
|
||||||
lastMessageAt?: string;
|
lastMessageAt?: string;
|
||||||
|
isGenerating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallInfo {
|
export interface ToolCallInfo {
|
||||||
@@ -321,6 +322,15 @@ export function useChat(projectId?: string): UseChatReturn {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recover streaming state if the server reports an active generation.
|
||||||
|
// After a reload/HMR, the server keeps generating but the UI loses
|
||||||
|
// all streaming state. Showing "Connecting…" immediately tells the
|
||||||
|
// user the AI is still working.
|
||||||
|
if (session?.isGenerating) {
|
||||||
|
setIsStreaming(true);
|
||||||
|
setStreamingText("");
|
||||||
|
}
|
||||||
|
|
||||||
// Persist active session to localStorage
|
// Persist active session to localStorage
|
||||||
if (id) {
|
if (id) {
|
||||||
setScopedItem(ACTIVE_SESSION_STORAGE_KEY, id, projectId);
|
setScopedItem(ACTIVE_SESSION_STORAGE_KEY, id, projectId);
|
||||||
@@ -685,6 +695,26 @@ export function useChat(projectId?: string): UseChatReturn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recovery mode: isStreaming is true but there's no active stream (streamRef is null).
|
||||||
|
// This happens after a page reload/HMR when the server is still generating.
|
||||||
|
// When the assistant message arrives via SSE, add it and clear the recovery state.
|
||||||
|
if (
|
||||||
|
activeSessionRef.current?.id === message.sessionId &&
|
||||||
|
isStreamingRef.current &&
|
||||||
|
!streamRef.current &&
|
||||||
|
message.role === "assistant"
|
||||||
|
) {
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.some((m) => m.id === message.id)) return prev;
|
||||||
|
return [...prev, message];
|
||||||
|
});
|
||||||
|
setStreamingText("");
|
||||||
|
setStreamingThinking("");
|
||||||
|
setStreamingToolCalls([]);
|
||||||
|
setIsStreaming(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Only add if this is the active session AND we're not streaming
|
// Only add if this is the active session AND we're not streaming
|
||||||
// (during streaming, messages are managed locally to avoid duplicates)
|
// (during streaming, messages are managed locally to avoid duplicates)
|
||||||
// Use ref to get the current value (state may not be updated yet when handler runs)
|
// Use ref to get the current value (state may not be updated yet when handler runs)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ChatMessage, ChatSession } from "@fusion/core";
|
|||||||
import {
|
import {
|
||||||
fetchResumeChatSession,
|
fetchResumeChatSession,
|
||||||
fetchChatSessions,
|
fetchChatSessions,
|
||||||
|
fetchChatSession,
|
||||||
createChatSession,
|
createChatSession,
|
||||||
fetchChatMessages,
|
fetchChatMessages,
|
||||||
streamChatResponse,
|
streamChatResponse,
|
||||||
@@ -173,6 +174,8 @@ export function useQuickChat(
|
|||||||
const cancelledByUserRef = useRef(false);
|
const cancelledByUserRef = useRef(false);
|
||||||
const cancelStreamingFlushesRef = useRef<(() => void) | null>(null);
|
const cancelStreamingFlushesRef = useRef<(() => void) | null>(null);
|
||||||
const pendingMessageRef = useRef("");
|
const pendingMessageRef = useRef("");
|
||||||
|
const isStreamingRef = useRef(isStreaming);
|
||||||
|
isStreamingRef.current = isStreaming;
|
||||||
const sendCompletionRef = useRef<{ resolve: () => void; reject: (error?: unknown) => void } | null>(null);
|
const sendCompletionRef = useRef<{ resolve: () => void; reject: (error?: unknown) => void } | null>(null);
|
||||||
|
|
||||||
// Track the current selected chat target for session management
|
// Track the current selected chat target for session management
|
||||||
@@ -234,6 +237,14 @@ export function useQuickChat(
|
|||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
setActiveSession(existingSession);
|
setActiveSession(existingSession);
|
||||||
currentSessionKeyRef.current = sessionKey;
|
currentSessionKeyRef.current = sessionKey;
|
||||||
|
|
||||||
|
// Recover streaming state if server is still generating for this session.
|
||||||
|
// After a reload/HMR, the server keeps generating but the UI loses
|
||||||
|
// all streaming state. Show the "Connecting…" indicator immediately.
|
||||||
|
if (existingSession.isGenerating) {
|
||||||
|
setIsStreaming(true);
|
||||||
|
setStreamingText("");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const newSession = await createSessionForTarget(target);
|
const newSession = await createSessionForTarget(target);
|
||||||
setActiveSession(newSession);
|
setActiveSession(newSession);
|
||||||
@@ -273,6 +284,41 @@ export function useQuickChat(
|
|||||||
}
|
}
|
||||||
}, [activeSession, loadMessages]);
|
}, [activeSession, loadMessages]);
|
||||||
|
|
||||||
|
// Poll for generation completion during recovery mode.
|
||||||
|
// Recovery mode: isStreaming=true but streamRef.current is null (no local stream).
|
||||||
|
// This happens after a reload/HMR when the server is still generating.
|
||||||
|
// Poll every 3s until the server reports isGenerating=false, then reload messages
|
||||||
|
// and clear streaming state.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStreaming || streamRef.current || !activeSession) return;
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
// Re-check conditions inside the callback (state may have changed)
|
||||||
|
if (!isStreamingRef.current || streamRef.current || !activeSession) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchChatSession(activeSession.id, projectId);
|
||||||
|
if (!data.session.isGenerating) {
|
||||||
|
clearInterval(interval);
|
||||||
|
// Reload messages to pick up the completed assistant message
|
||||||
|
const msgData = await fetchChatMessages(activeSession.id, { limit: 50 }, projectId);
|
||||||
|
setMessages(msgData.messages.map(mapChatMessageToInfo));
|
||||||
|
setStreamingText("");
|
||||||
|
setStreamingThinking("");
|
||||||
|
setStreamingToolCalls([]);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fail - will retry on next interval
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isStreaming, activeSession, projectId]);
|
||||||
|
|
||||||
// Reload messages from server (for same-session revisit)
|
// Reload messages from server (for same-session revisit)
|
||||||
const reloadMessages = useCallback(async () => {
|
const reloadMessages = useCallback(async () => {
|
||||||
if (!activeSession) return;
|
if (!activeSession) return;
|
||||||
|
|||||||
@@ -1256,3 +1256,132 @@ describe("ChatManager diagnostics", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ChatManager.isGenerating", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
__resetChatState();
|
||||||
|
mockChatStore.getSession.mockReturnValue({
|
||||||
|
id: "chat-001",
|
||||||
|
agentId: "agent-1",
|
||||||
|
status: "active",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
mockChatStore.addMessage.mockReturnValue({
|
||||||
|
id: "msg-1",
|
||||||
|
sessionId: "chat-001",
|
||||||
|
role: "user",
|
||||||
|
content: "Hello",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
mockSummarizeTitle.mockResolvedValue("Test Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no generation is active", () => {
|
||||||
|
const chatManager = createChatManager();
|
||||||
|
expect(chatManager.isGenerating("chat-001")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true during an active generation", async () => {
|
||||||
|
let resolvePrompt: () => void;
|
||||||
|
const promptPromise = new Promise<void>((resolve) => {
|
||||||
|
resolvePrompt = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
__setCreateFnAgent(async () => {
|
||||||
|
await promptPromise;
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
state: { messages: [{ role: "assistant", content: "Done" }] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatManager = createChatManager();
|
||||||
|
|
||||||
|
// Start the generation (don't await it — it blocks until resolvePrompt is called)
|
||||||
|
const sendPromise = chatManager.sendMessage("chat-001", "Hello");
|
||||||
|
|
||||||
|
// The generation should be active now
|
||||||
|
expect(chatManager.isGenerating("chat-001")).toBe(true);
|
||||||
|
expect(chatManager.isGenerating("chat-999")).toBe(false); // different session
|
||||||
|
|
||||||
|
// Complete the generation
|
||||||
|
resolvePrompt!();
|
||||||
|
await sendPromise;
|
||||||
|
|
||||||
|
// Generation should be cleared
|
||||||
|
expect(chatManager.isGenerating("chat-001")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatManager.getGeneratingSessionIds", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
__resetChatState();
|
||||||
|
mockSummarizeTitle.mockResolvedValue("Test Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no generations are active", () => {
|
||||||
|
const chatManager = createChatManager();
|
||||||
|
expect(chatManager.getGeneratingSessionIds()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all session IDs with active generations", async () => {
|
||||||
|
let resolvePrompt1: () => void;
|
||||||
|
let resolvePrompt2: () => void;
|
||||||
|
const promptPromise1 = new Promise<void>((resolve) => { resolvePrompt1 = resolve; });
|
||||||
|
const promptPromise2 = new Promise<void>((resolve) => { resolvePrompt2 = resolve; });
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
__setCreateFnAgent(async () => {
|
||||||
|
callCount++;
|
||||||
|
const promise = callCount === 1 ? promptPromise1 : promptPromise2;
|
||||||
|
await promise;
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
state: { messages: [{ role: "assistant", content: "Done" }] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
mockChatStore.getSession.mockImplementation((id: string) => ({
|
||||||
|
id,
|
||||||
|
agentId: "agent-1",
|
||||||
|
status: "active",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
mockChatStore.addMessage.mockReturnValue({
|
||||||
|
id: "msg-1",
|
||||||
|
sessionId: "chat-001",
|
||||||
|
role: "user",
|
||||||
|
content: "Hello",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatManager = createChatManager();
|
||||||
|
|
||||||
|
// Start two generations
|
||||||
|
const send1 = chatManager.sendMessage("chat-001", "Hello");
|
||||||
|
const send2 = chatManager.sendMessage("chat-002", "World");
|
||||||
|
|
||||||
|
// Both should show as generating
|
||||||
|
const ids = chatManager.getGeneratingSessionIds();
|
||||||
|
expect(ids).toContain("chat-001");
|
||||||
|
expect(ids).toContain("chat-002");
|
||||||
|
expect(ids).toHaveLength(2);
|
||||||
|
|
||||||
|
// Complete both
|
||||||
|
resolvePrompt1!();
|
||||||
|
resolvePrompt2!();
|
||||||
|
await Promise.all([send1, send2]);
|
||||||
|
|
||||||
|
expect(chatManager.getGeneratingSessionIds()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -922,6 +922,21 @@ export class ChatManager {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a generation is currently in progress for the given session.
|
||||||
|
*/
|
||||||
|
isGenerating(sessionId: string): boolean {
|
||||||
|
return this.activeGenerations.has(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all session IDs that currently have an active generation.
|
||||||
|
* Useful for batch-enriching session lists without N+1 lookups.
|
||||||
|
*/
|
||||||
|
getGeneratingSessionIds(): string[] {
|
||||||
|
return [...this.activeGenerations.keys()];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Test Helpers ────────────────────────────────────────────────────────────
|
// ── Test Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -120,16 +120,21 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
|||||||
const sessionIds = sessions.map((s) => s.id);
|
const sessionIds = sessions.map((s) => s.id);
|
||||||
const lastMessages = chatStore.getLastMessageForSessions(sessionIds);
|
const lastMessages = chatStore.getLastMessageForSessions(sessionIds);
|
||||||
|
|
||||||
|
// Batch-gather generating session IDs to avoid N+1 calls
|
||||||
|
const generatingIds = options?.chatManager?.getGeneratingSessionIds?.() ?? [];
|
||||||
|
const generatingSet = new Set(generatingIds);
|
||||||
|
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const lastMessage = lastMessages.get(session.id);
|
const lastMessage = lastMessages.get(session.id);
|
||||||
|
const enriched: EnrichedChatSession = session;
|
||||||
if (lastMessage) {
|
if (lastMessage) {
|
||||||
// Truncate content to 100 chars for preview
|
// Truncate content to 100 chars for preview
|
||||||
const content = lastMessage.content || "";
|
const content = lastMessage.content || "";
|
||||||
const enriched: EnrichedChatSession = session;
|
|
||||||
enriched.lastMessagePreview =
|
enriched.lastMessagePreview =
|
||||||
content.length > 100 ? content.slice(0, 100) + "…" : content;
|
content.length > 100 ? content.slice(0, 100) + "…" : content;
|
||||||
enriched.lastMessageAt = lastMessage.createdAt;
|
enriched.lastMessageAt = lastMessage.createdAt;
|
||||||
}
|
}
|
||||||
|
enriched.isGenerating = generatingSet.has(session.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +244,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
|||||||
throw notFound(`Chat session ${sessionId} not found`);
|
throw notFound(`Chat session ${sessionId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ session });
|
const enriched: EnrichedChatSession = session;
|
||||||
|
enriched.isGenerating = options?.chatManager?.isGenerating?.(sessionId) ?? false;
|
||||||
|
|
||||||
|
res.json({ session: enriched });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
Reference in New Issue
Block a user