Files
multica/packages/core/chat/message-cache.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

136 lines
5.3 KiB
TypeScript

import type { InfiniteData, QueryClient } from "@tanstack/react-query";
import { chatKeys } from "./queries";
import type { ChatMessage, ChatMessagesPage } from "../types";
/**
* The single door a settled chat message goes through on its way into the
* message caches (MUL-5711).
*
* Before this, every writer rolled its own: `chat:done` inline-inserted the
* assistant reply, `chat:message` threw its payload away and only invalidated,
* and the three send paths each appended by hand — two of them to both caches,
* the Agent Builder to the flat one only. The gap that produced this bug was in
* that inconsistency, not in any single writer: a human's own message was the
* one row no WebSocket handler ever wrote, so it reached the transcript solely
* through a refetch, and a refetch that gets cancelled takes the message with
* it.
*
* Two rules, both order-independent, so the same message arriving twice by
* different routes converges instead of duplicating or downgrading:
*
* 1. Identity is `message.id`. A second write for an id already present
* merges instead of appending.
* 2. On merge the row already in cache wins every field it defines; the
* incoming row only fills fields that are missing. A send response
* carrying `attachments` therefore survives the WebSocket echo that has
* no attachments field at all — in either arrival order.
*/
/** Fields present on `incoming` fill only the gaps in `existing`. */
function mergeChatMessage(existing: ChatMessage, incoming: ChatMessage): ChatMessage {
const fills: Partial<ChatMessage> = {};
let filled = false;
for (const key of Object.keys(incoming) as (keyof ChatMessage)[]) {
if (existing[key] !== undefined || incoming[key] === undefined) continue;
Object.assign(fills, { [key]: incoming[key] });
filled = true;
}
// Reference-stable when nothing was filled, so observers do not re-render.
return filled ? { ...existing, ...fills } : existing;
}
/**
* Merge into the row with this id if the list has one. Never appends — the
* caller decides where a genuinely new message goes, which for a paged cache is
* one specific page.
*/
function mergeIntoList(messages: ChatMessage[], message: ChatMessage): ChatMessage[] {
const index = messages.findIndex((m) => m.id === message.id);
if (index < 0) return messages;
const merged = mergeChatMessage(messages[index]!, message);
if (merged === messages[index]) return messages;
return messages.map((m, i) => (i === index ? merged : m));
}
function upsertIntoList(messages: ChatMessage[], message: ChatMessage): ChatMessage[] {
const merged = mergeIntoList(messages, message);
if (merged !== messages) return merged;
return messages.some((m) => m.id === message.id) ? messages : [...messages, message];
}
function upsertIntoPages(
data: InfiniteData<ChatMessagesPage>,
message: ChatMessage,
): InfiniteData<ChatMessagesPage> {
const seen = data.pages.some((page) => page.messages.some((m) => m.id === message.id));
if (seen) {
// Merge in place, wherever the row already lives. Appending here instead
// would drop a copy into every OTHER page.
let changed = false;
const pages = data.pages.map((page) => {
const next = mergeIntoList(page.messages, message);
if (next === page.messages) return page;
changed = true;
return { ...page, messages: next };
});
return changed ? { ...data, pages } : data;
}
// Page 0 is the newest window (later pages are older cursor pages), so a new
// message belongs at the end of it.
return {
...data,
pages: data.pages.map((page, index) =>
index === 0 ? { ...page, messages: [...page.messages, message] } : page,
),
};
}
function seedPage(message: ChatMessage): InfiniteData<ChatMessagesPage> {
return {
pages: [{ messages: [message], limit: 50, has_more: false, next_cursor: null }],
pageParams: [null],
};
}
export interface UpsertChatMessageOptions {
/**
* Create the cache entry when the session has never been fetched.
*
* Send paths pass `true`: a brand-new session must render its first message
* before any fetch exists, and the send's own invalidate reconciles the
* placeholder page immediately after.
*
* WebSocket handlers pass `false` (the default). An event for a session this
* client has not opened must not fabricate a one-message history that a later
* reader would mistake for the whole transcript — the first real fetch owns
* that.
*/
seedIfMissing?: boolean;
}
/**
* Upsert one settled message into both message caches.
*
* Both are written because both are read: `chatKeys.messagesPage` backs the
* chat surfaces, `chatKeys.messages` backs the Agent Builder. Writing one and
* not the other is how they drift.
*/
export function upsertChatMessageToCaches(
qc: QueryClient,
sessionId: string,
message: ChatMessage,
{ seedIfMissing = false }: UpsertChatMessageOptions = {},
): void {
qc.setQueryData<ChatMessage[] | undefined>(chatKeys.messages(sessionId), (old) => {
if (!old) return seedIfMissing ? [message] : old;
return upsertIntoList(old, message);
});
qc.setQueryData<InfiniteData<ChatMessagesPage> | undefined>(
chatKeys.messagesPage(sessionId),
(old) => {
if (!old?.pages.length) return seedIfMissing ? seedPage(message) : old;
return upsertIntoPages(old, message);
},
);
}