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]>
136 lines
5.3 KiB
TypeScript
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);
|
|
},
|
|
);
|
|
}
|