Files
multica/packages/core/chat/message-cache.test.ts
c7a8b1699f fix(chat): route settled chat messages through one cache door (MUL-5711) (#6559)
* 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]>
2026-08-07 14:59:28 +08:00

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"]]);
});
});