mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
* fix(chat): route settled chat messages through one cache door (MUL-5711) A human's own chat message was the only row in the transcript that no WebSocket handler ever wrote into the cache. `chat:message` carries the whole message (id, role, content, task_id, created_at) but the handler read `chat_session_id` off it and dropped the rest, so a user's prompt reached the UI solely through the refetch that invalidate scheduled — while the agent's reply is inline-inserted by `chat:done` and is never at risk. Any client that did not write the message locally (a second window or device, a send whose HTTP response failed after the server committed, a surface mounting mid-flight) lost it whenever that refetch was dropped. The refetch is dropped by the quick-actions supplement: it cancels the in-flight messages fetch before patching, and `cancelQueries` defaults to `revert: true`, so the cache is rolled BACK to the snapshot taken when that fetch started rather than merely left alone. Both message caches are `staleTime: Infinity` with no further trigger, so the hole survived until a remount — which is why a refresh always brought the message back. - Add `upsertChatMessageToCaches` in core: one door for settled messages, writing both the flat and paged caches, idempotent by `message.id`. On a repeat the cached row wins every field it defines and the incoming row fills only what is missing, so a send response carrying attachments and its attachment-less `chat:message` echo converge in either arrival order. Merges happen in place; only a genuinely new message is appended, and only to the newest page. - Route all five writers through it: `chat:message` (now writes the user turn instead of discarding the payload), `chat:done`, the chat page send, the floating window send, and the Agent Builder send — which wrote the flat cache only, leaving the paged cache the chat surfaces read without the message. - `applyChatQuickActionsToCache` re-invalidates after its cancel+patch. This cannot lose the pills: the server persists the actions before broadcasting the event (SupplementChatQuickActions). - Drop the two copied `appendChatMessageToLatestPageCache` helpers and the now-unused `patchLatestChatMessagePage`. The virtual-list / `firstItemIndex` change from the earlier attempt is deliberately NOT here — it is a separate defect (scroll drift, MUL-1613) and gets its own PR. Verified: pnpm typecheck (6/6), pnpm lint (no new problems), pnpm test — core 1338, views 3718, desktop 442, web 188. The quick-actions regression test fails with the re-invalidate removed. Co-Authored-By: Claude Opus 5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(agents): clear the paged message cache in Builder cleanup (MUL-5711 review) Routing the Agent Builder send through upsertChatMessageToCaches made it seed `chatKeys.messagesPage` as well as the flat cache, but its cleanup paths still only knew about the flat one. `messagesPage` is staleTime: Infinity, so anything left there stays fresh forever and any chat surface that later opens the same session reads it instead of the server — the same "one cache updated, the other drifts permanently" shape this PR exists to remove. - destroy: remove the paged cache alongside the flat one, so a discarded conversation cannot leave its transcript behind. - stop: invalidate the paged cache on both the success and the failure path. A failed cancel may still have landed server-side, so the failure path now re-reads the messages too instead of only the pending marker. - stop: when the cancel reports a restored prompt, drop that message from both caches before reconciling — same order as the chat surfaces' cancelChatTask. The server deleted that row; without this the paged cache keeps serving it. Verified: pnpm typecheck (6/6), pnpm lint (no new problems), pnpm test — core 1338, views 3722, desktop 442, web 188, docs 17. The three cleanup tests fail with this commit's fix reverted. Co-Authored-By: Claude Opus 5 <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Claude Opus 5 <[email protected]> Co-authored-by: multica-agent <[email protected]>
141 lines
5.1 KiB
TypeScript
141 lines
5.1 KiB
TypeScript
import { QueryClient, type InfiniteData } from "@tanstack/react-query";
|
|
import { describe, expect, it } from "vitest";
|
|
import { chatKeys } from "./queries";
|
|
import { upsertChatMessageToCaches } from "./message-cache";
|
|
import type { ChatMessage, ChatMessagesPage } from "../types";
|
|
|
|
const sessionId = "session-1";
|
|
const flatKey = chatKeys.messages(sessionId);
|
|
const pageKey = chatKeys.messagesPage(sessionId);
|
|
|
|
function qcWith(messages: ChatMessage[] | null, pages?: ChatMessagesPage[]) {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
if (messages) qc.setQueryData<ChatMessage[]>(flatKey, messages);
|
|
if (pages) {
|
|
qc.setQueryData<InfiniteData<ChatMessagesPage>>(pageKey, {
|
|
pages,
|
|
pageParams: pages.map((_, i) => (i === 0 ? null : { created_at: "x", id: "x" })),
|
|
});
|
|
}
|
|
return qc;
|
|
}
|
|
|
|
function message(overrides: Partial<ChatMessage> = {}): ChatMessage {
|
|
return {
|
|
id: "msg-1",
|
|
chat_session_id: sessionId,
|
|
role: "user",
|
|
content: "hello",
|
|
task_id: "task-1",
|
|
created_at: "2026-05-13T05:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function page(messages: ChatMessage[]): ChatMessagesPage {
|
|
return { messages, limit: 50, has_more: false, next_cursor: null };
|
|
}
|
|
|
|
function flatIds(qc: QueryClient) {
|
|
return qc.getQueryData<ChatMessage[]>(flatKey)?.map((m) => m.id);
|
|
}
|
|
|
|
function pageIds(qc: QueryClient) {
|
|
return qc
|
|
.getQueryData<InfiniteData<ChatMessagesPage>>(pageKey)
|
|
?.pages.map((p) => p.messages.map((m) => m.id));
|
|
}
|
|
|
|
describe("upsertChatMessageToCaches", () => {
|
|
it("writes into both caches in one call", () => {
|
|
const existing = message({ id: "msg-0" });
|
|
const qc = qcWith([existing], [page([existing])]);
|
|
|
|
upsertChatMessageToCaches(qc, sessionId, message());
|
|
|
|
expect(flatIds(qc)).toEqual(["msg-0", "msg-1"]);
|
|
expect(pageIds(qc)).toEqual([["msg-0", "msg-1"]]);
|
|
});
|
|
|
|
it("appends to the newest page only, never to older pages", () => {
|
|
const older = message({ id: "msg-old", created_at: "2026-05-13T04:00:00Z" });
|
|
const latest = message({ id: "msg-latest" });
|
|
// Page 0 is the newest window; later pages are older cursor pages.
|
|
const qc = qcWith(null, [page([latest]), page([older])]);
|
|
|
|
upsertChatMessageToCaches(qc, sessionId, message({ id: "msg-new" }));
|
|
upsertChatMessageToCaches(qc, sessionId, message({ id: "msg-new" }));
|
|
|
|
expect(pageIds(qc)).toEqual([["msg-latest", "msg-new"], ["msg-old"]]);
|
|
});
|
|
|
|
it("merges a repeat instead of appending, wherever the row lives", () => {
|
|
const inOlderPage = message({ id: "msg-old" });
|
|
const qc = qcWith([inOlderPage], [page([]), page([inOlderPage])]);
|
|
|
|
upsertChatMessageToCaches(qc, sessionId, message({ id: "msg-old", elapsed_ms: 42 }));
|
|
|
|
expect(pageIds(qc)).toEqual([[], ["msg-old"]]);
|
|
expect(
|
|
qc.getQueryData<InfiniteData<ChatMessagesPage>>(pageKey)?.pages[1]?.messages[0]
|
|
?.elapsed_ms,
|
|
).toBe(42);
|
|
});
|
|
|
|
// The reason the merge direction matters: the send response carries draft
|
|
// attachments, the chat:message echo of the same send has no attachments
|
|
// field at all. Whichever lands first, the attachments must survive.
|
|
it("keeps the richer row regardless of arrival order", () => {
|
|
const withAttachments = message({ attachments: [{ id: "att-1" } as never] });
|
|
const fromWs = message();
|
|
|
|
const sendFirst = qcWith([withAttachments], [page([withAttachments])]);
|
|
upsertChatMessageToCaches(sendFirst, sessionId, fromWs);
|
|
expect(sendFirst.getQueryData<ChatMessage[]>(flatKey)?.[0]?.attachments).toHaveLength(1);
|
|
|
|
const wsFirst = qcWith([fromWs], [page([fromWs])]);
|
|
upsertChatMessageToCaches(wsFirst, sessionId, withAttachments);
|
|
expect(wsFirst.getQueryData<ChatMessage[]>(flatKey)?.[0]?.attachments).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps the row reference stable when nothing was filled", () => {
|
|
const existing = message({ elapsed_ms: 7 });
|
|
const qc = qcWith([existing], [page([existing])]);
|
|
|
|
upsertChatMessageToCaches(qc, sessionId, message({ elapsed_ms: 999, content: "x" }));
|
|
|
|
expect(qc.getQueryData<ChatMessage[]>(flatKey)?.[0]).toBe(existing);
|
|
});
|
|
|
|
it("leaves an unfetched cache alone by default", () => {
|
|
const qc = qcWith(null);
|
|
|
|
upsertChatMessageToCaches(qc, sessionId, message());
|
|
|
|
expect(qc.getQueryData(flatKey)).toBeUndefined();
|
|
expect(qc.getQueryData(pageKey)).toBeUndefined();
|
|
});
|
|
|
|
it("seeds both caches for a brand-new session when asked", () => {
|
|
const qc = qcWith(null);
|
|
|
|
upsertChatMessageToCaches(qc, sessionId, message(), { seedIfMissing: true });
|
|
|
|
expect(flatIds(qc)).toEqual(["msg-1"]);
|
|
expect(pageIds(qc)).toEqual([["msg-1"]]);
|
|
const seeded = qc.getQueryData<InfiniteData<ChatMessagesPage>>(pageKey);
|
|
expect(seeded?.pages[0]?.has_more).toBe(false);
|
|
expect(seeded?.pageParams).toEqual([null]);
|
|
});
|
|
|
|
it("does not seed twice when the same send is applied again", () => {
|
|
const qc = qcWith(null);
|
|
|
|
upsertChatMessageToCaches(qc, sessionId, message(), { seedIfMissing: true });
|
|
upsertChatMessageToCaches(qc, sessionId, message(), { seedIfMissing: true });
|
|
|
|
expect(flatIds(qc)).toEqual(["msg-1"]);
|
|
expect(pageIds(qc)).toEqual([["msg-1"]]);
|
|
});
|
|
});
|