feat(FN-3927): persist and restore durable chat recovery state

- Persist in-flight chat generation snapshots in core chat store types and DB plumbing
- Restore durable recovery state through dashboard chat manager and chat hooks on reload
- Add coverage for chat store persistence and chat/useQuickChat recovery behavior
- Document durable chat recovery architecture and dashboard behavior

Fusion-Task-Id: FN-3927
This commit is contained in:
Fusion
2026-05-10 09:31:00 -07:00
committed by gsxdsm
parent 7e9ba31df9
commit d5f12a5c95
13 changed files with 288 additions and 24 deletions

View File

@@ -2028,6 +2028,47 @@ describe("useChat", () => {
expect(result.current.streamingToolCalls).toHaveLength(1);
});
it("hydrates durable in-flight snapshot and resumes from replay point", async () => {
const session = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
isGenerating: true,
inFlightGeneration: {
status: "generating" as const,
streamingText: "partial text",
streamingThinking: "partial thinking",
toolCalls: [{ toolName: "read", status: "running" as const, isError: false }],
replayFromEventId: 41,
updatedAt: "2026-04-08T00:00:00.000Z",
},
};
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("partial text");
expect(result.current.streamingThinking).toBe("partial thinking");
expect(result.current.streamingToolCalls).toHaveLength(1);
});
expect(mockAttachChatStream).toHaveBeenCalledWith(
"session-001",
expect.any(Object),
"proj-123",
{ lastEventId: 41 },
);
});
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] });

View File

@@ -1275,6 +1275,43 @@ describe("useQuickChat", () => {
});
describe("FN-3336: streaming state recovery on reload", () => {
it("hydrates durable in-flight snapshot and resumes from replay point", async () => {
const session = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
isGenerating: true,
inFlightGeneration: {
status: "generating" as const,
streamingText: "partial text",
streamingThinking: "partial thinking",
toolCalls: [{ toolName: "read", status: "running" as const, isError: false }],
replayFromEventId: 17,
updatedAt: "2026-04-08T00:00:00.000Z",
},
};
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("partial text");
expect(result.current.streamingThinking).toBe("partial thinking");
expect(result.current.streamingToolCalls).toHaveLength(1);
});
expect(mockAttachChatStream).toHaveBeenCalledWith(
"session-001",
expect.any(Object),
"proj-123",
{ lastEventId: 17 },
);
});
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 });