feat(FN-3430): preserve and consume final assistant done payload

This merge adds done-payload snapshot handling across the chat system (FN-3430), normalizing how final assistant messages are preserved and consumed in the dashboard hooks, plus it normalizes dashboard mailbox and user identity for inter-agent messaging (FN-3484) and introduces plugin workflow step

Fusion-Task-Id: FN-3430
This commit is contained in:
Fusion
2026-05-05 06:38:21 -07:00
committed by gsxdsm
parent 47ae7783b7
commit b8a32f8765
12 changed files with 373 additions and 33 deletions

View File

@@ -456,6 +456,104 @@ describe("useChat", () => {
});
});
it("uses done payload assistant snapshot when no text chunks were streamed", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
let doneHandler: ((data: { messageId: string; message?: ChatMessage }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
doneHandler = handlers.onDone as typeof doneHandler;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await act(async () => {
result.current.sendMessage("Hello!");
});
act(() => {
doneHandler?.({
messageId: "msg-002",
message: {
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Snapshot reply",
thinkingOutput: null,
metadata: null,
createdAt: "2026-01-01T00:00:00.000Z",
} as ChatMessage,
});
});
await waitFor(() => {
expect(result.current.messages.at(-1)).toEqual(expect.objectContaining({
id: "msg-002",
role: "assistant",
content: "Snapshot reply",
}));
expect(result.current.isStreaming).toBe(false);
});
});
it("prefers done payload assistant snapshot over streamed text", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
let textHandler: ((data: string) => void) | undefined;
let doneHandler: ((data: { messageId: string; message?: ChatMessage }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
textHandler = handlers.onText;
doneHandler = handlers.onDone as typeof doneHandler;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
act(() => {
result.current.sendMessage("Hello!");
textHandler?.("streamed text");
doneHandler?.({
messageId: "msg-003",
message: {
id: "msg-003",
sessionId: "session-001",
role: "assistant",
content: "snapshot wins",
thinkingOutput: null,
metadata: null,
createdAt: "2026-01-01T00:00:00.000Z",
} as ChatMessage,
});
});
await waitFor(() => {
expect(result.current.messages.at(-1)).toEqual(expect.objectContaining({
id: "msg-003",
content: "snapshot wins",
}));
});
});
it("handles stream errors and surfaces them to the user", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });

View File

@@ -78,6 +78,92 @@ describe("useQuickChat", () => {
await expect(sendResult).resolves.toBeUndefined();
});
it("uses done payload assistant snapshot when no text chunks were streamed", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchResumeChatSession.mockResolvedValue({ session });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onDone?.({
messageId: "msg-001",
message: {
id: "msg-001",
sessionId: "session-001",
role: "assistant",
content: "Snapshot reply",
thinkingOutput: null,
metadata: null,
createdAt: "2026-01-01T00:00:00.000Z",
} as any,
});
}, 0);
return { close: vi.fn(), isConnected: () => true };
});
await act(async () => {
await result.current.sendMessage("Hello");
});
await waitFor(() => {
expect(result.current.messages.at(-1)).toEqual(expect.objectContaining({
id: "msg-001",
role: "assistant",
content: "Snapshot reply",
}));
});
});
it("prefers done payload assistant snapshot over streamed text", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchResumeChatSession.mockResolvedValue({ session });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
let onText: ((data: string) => void) | undefined;
let onDone: ((data: { messageId: string; message?: any }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
onText = handlers.onText;
onDone = handlers.onDone as typeof onDone;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
act(() => {
void result.current.sendMessage("Hello");
onText?.("streamed text");
onDone?.({
messageId: "msg-002",
message: {
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "snapshot wins",
thinkingOutput: null,
metadata: null,
createdAt: "2026-01-01T00:00:00.000Z",
},
});
});
await waitFor(() => {
expect(result.current.messages.at(-1)).toEqual(expect.objectContaining({
id: "msg-002",
content: "snapshot wins",
}));
});
});
it("startModelChat creates a KB session with provider/model override", async () => {
const { result } = renderHook(() => useQuickChat("proj-123"));