Files
multica/packages/core/realtime/use-realtime-sync.test.ts
Naiyuan Qing f2f17e3355 Optimize chat message loading (#3685)
* Optimize chat message loading

Co-authored-by: multica-agent <github@multica.ai>

* Fix chat history cursor pagination

Co-authored-by: multica-agent <github@multica.ai>

* Fix chat session list remount key

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): fall back to legacy /messages when paged endpoint 404s

Deployment-order compatibility: a backend deployed before the
/messages/page endpoint existed returns 404 for the unknown route.
The cursorless initial page now falls back to the legacy full-list
/messages endpoint and wraps it in a single has_more:false page, so
chat never white-screens regardless of which side deploys first. A 404
on a cursor request still propagates to avoid duplicating the full list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:47:30 +08:00

236 lines
6.9 KiB
TypeScript

import { QueryClient, type InfiniteData } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { chatKeys } from "../chat/queries";
import { issueKeys } from "../issues/queries";
import { workspaceKeys } from "../workspace/queries";
import type {
ChatDonePayload,
ChatMessage,
ChatPendingTask,
ChatMessagesPage,
Workspace,
} from "../types";
import {
applyChatDoneToCache,
applyWorkspaceUpdatedToCache,
} from "./use-realtime-sync";
const sessionId = "session-1";
const taskId = "task-1";
const messagesKey = chatKeys.messages(sessionId);
const pendingKey = chatKeys.pendingTask(sessionId);
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
}
function userMessage(): ChatMessage {
return {
id: "msg-user",
chat_session_id: sessionId,
role: "user",
content: "hello",
task_id: null,
created_at: "2026-05-13T05:00:00Z",
};
}
function donePayload(overrides: Partial<ChatDonePayload> = {}): ChatDonePayload {
return {
chat_session_id: sessionId,
task_id: taskId,
message_id: "msg-assistant",
content: "done",
elapsed_ms: 1234,
created_at: "2026-05-13T05:00:02Z",
...overrides,
};
}
describe("applyChatDoneToCache", () => {
it("writes the assistant message before clearing pending task", () => {
const qc = createQueryClient();
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
const setQueryData = vi.spyOn(qc, "setQueryData");
applyChatDoneToCache(qc, donePayload());
expect(setQueryData.mock.calls[0]?.[0]).toEqual(messagesKey);
expect(setQueryData.mock.calls[2]?.[0]).toEqual(pendingKey);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
{
id: "msg-assistant",
chat_session_id: sessionId,
role: "assistant",
content: "done",
task_id: taskId,
created_at: "2026-05-13T05:00:02Z",
elapsed_ms: 1234,
},
]);
});
it("does not duplicate a replayed chat done event", () => {
const qc = createQueryClient();
const assistant: ChatMessage = {
id: "msg-assistant",
chat_session_id: sessionId,
role: "assistant",
content: "done",
task_id: taskId,
created_at: "2026-05-13T05:00:02Z",
elapsed_ms: 1234,
};
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage(), assistant]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
applyChatDoneToCache(qc, donePayload());
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
assistant,
]);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
it("falls back to invalidation-only when older servers omit message fields", () => {
const qc = createQueryClient();
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
applyChatDoneToCache(
qc,
donePayload({ message_id: undefined, content: undefined }),
);
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
]);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
});
describe("applyWorkspaceUpdatedToCache", () => {
const wsId = "ws-1";
function workspace(overrides: Partial<Workspace> = {}): Workspace {
return {
id: wsId,
name: "Test",
slug: "test",
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "TES",
avatar_url: null,
created_at: "2026-05-18T00:00:00Z",
updated_at: "2026-05-18T00:00:00Z",
...overrides,
};
}
it("invalidates issue cache when issue_prefix changes", () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ issue_prefix: "TES" }),
]);
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "NEW" }),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: workspaceKeys.list(),
});
});
it("does not invalidate issue cache when only non-prefix fields change", () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ issue_prefix: "TES", name: "Old name" }),
]);
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "TES", name: "New name" }),
});
expect(invalidate).not.toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: workspaceKeys.list(),
});
});
it("invalidates issue cache when the workspace isn't in the cached list yet", () => {
// Conservative: a workspace appearing for the first time may correspond
// to issue queries that were primed without ever seeing the (possibly
// changing) prefix. Erring on the side of refresh keeps identifiers
// accurate at minimal cost.
const qc = createQueryClient();
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "NEW" }),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
});
});
describe("applyChatDoneToCache paged messages", () => {
it("patches page zero and skips older pages without duplicating replayed events", () => {
const qc = createQueryClient();
const older = userMessage();
const latest: ChatMessage = {
id: "msg-latest",
chat_session_id: sessionId,
role: "user",
content: "latest",
task_id: null,
created_at: "2026-05-13T05:00:01Z",
};
qc.setQueryData<InfiniteData<ChatMessagesPage>>(chatKeys.messagesPage(sessionId), {
pages: [
{ messages: [latest], limit: 1, has_more: true, next_cursor: { created_at: latest.created_at, id: latest.id } },
{ messages: [older], limit: 1, has_more: false, next_cursor: null },
],
pageParams: [null, { created_at: latest.created_at, id: latest.id }],
});
applyChatDoneToCache(qc, donePayload());
applyChatDoneToCache(qc, donePayload());
const paged = qc.getQueryData<InfiniteData<ChatMessagesPage>>(chatKeys.messagesPage(sessionId));
expect(paged?.pages[0]?.messages.map((m) => m.id)).toEqual(["msg-latest", "msg-assistant"]);
expect(paged?.pages[1]?.messages.map((m) => m.id)).toEqual(["msg-user"]);
});
});