Compare commits

...

1 Commits

Author SHA1 Message Date
Arvin Xu def6029d30 ️ perf: write message mutations through to the message:list SWR cache
Message mutations only touched the in-memory store, so the message:list
SWR/IndexedDB cache stayed stale until a network refetch. Because the
Conversation store is recreated on every topic/session switch and
re-hydrates from that cache, the stale cache is what forced a refetch on
every switch.

replaceMessages now seeds the message:list cache for the exact bucket via
mutate(matcher, messages, { revalidate: false }). Skips the
useFetchMessages onData sync path (SWR already holds it) and skips while
the context is streaming to avoid per-token IndexedDB thrash; the
agent_runtime_end snapshot still writes through since it clears the
running flag first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:12:06 +08:00
2 changed files with 110 additions and 0 deletions
@@ -1220,6 +1220,72 @@ describe('chatMessage actions', () => {
});
});
describe('replaceMessages cache write-through', () => {
beforeEach(() => {
(mutate as Mock).mockClear();
});
it('seeds the message:list SWR cache for the same bucket without revalidating', async () => {
const { result } = renderHook(() => useChatStore());
const context = { agentId: 'wt-agent', topicId: 'wt-topic' };
const messages = [{ id: 'm1', role: 'user', content: 'hi' }] as any;
await act(async () => {
result.current.replaceMessages(messages, { context });
});
expect(mutate).toHaveBeenCalledTimes(1);
const [matcher, dataArg, options] = (mutate as Mock).mock.calls[0];
// seeds, never refetches
expect(options).toEqual({ revalidate: false });
expect(dataArg).toEqual(messages);
// matcher targets exactly this bucket, not other buckets / domains
expect(typeof matcher).toBe('function');
expect(matcher(['message:list', context, 1])).toBe(true);
// workspace-augmented variant of the same bucket still matches
expect(matcher(['message:list', context, 1, 'workspace-1'])).toBe(true);
expect(matcher(['message:list', { agentId: 'wt-agent', topicId: 'other' }, 1])).toBe(false);
expect(matcher(['topic:list', 'container', {}])).toBe(false);
});
it('skips write-through for the useFetchMessages onData sync path', async () => {
const { result } = renderHook(() => useChatStore());
await act(async () => {
result.current.replaceMessages([{ id: 'm2', role: 'user', content: 'hi' }] as any, {
action: 'useFetchMessages',
context: { agentId: 'wt-agent-2', topicId: 'wt-topic-2' },
});
});
expect(mutate).not.toHaveBeenCalled();
});
it('skips write-through while the context is streaming', async () => {
const { result } = renderHook(() => useChatStore());
const context = { agentId: 'wt-agent-3', topicId: 'wt-topic-3' };
await act(async () => {
// A running AI-runtime op marks the context as streaming.
result.current.startOperation({ type: 'execAgentRuntime', context });
});
(mutate as Mock).mockClear();
await act(async () => {
result.current.replaceMessages([{ id: 'm3', role: 'user', content: 'hi' }] as any, {
context,
});
});
expect(mutate).not.toHaveBeenCalled();
});
});
describe('Public API with context parameter', () => {
describe('deleteMessage with context', () => {
it('should pass context to optimisticDeleteMessages', async () => {
@@ -6,6 +6,7 @@ import { type SWRResponse } from 'swr';
import { mutate, useClientDataSWRWithSync } from '@/libs/swr';
import { messageKeys } from '@/libs/swr/keys';
import { messageService } from '@/services/message';
import { operationSelectors } from '@/store/chat/slices/operation/selectors';
import { type ChatStore } from '@/store/chat/store';
import { type StoreSetter } from '@/store/types';
@@ -112,6 +113,49 @@ export class MessageQueryActionImpl {
false,
params?.action ?? 'replaceMessages',
);
this.#writeThroughMessageCache(ctx, messagesKey, reconciled, params?.action);
};
/**
* Write the settled in-memory messages back into the `message:list` SWR cache
* (and, transitively, the persisted IndexedDB tier) for this exact bucket.
*
* Why: message mutations otherwise only touch the in-memory store, so the SWR
* cache stays stale until a network refetch. Because the Conversation store is
* recreated on every topic/session switch and re-hydrates from this cache, a
* stale cache is what forces a refetch on every switch. Keeping the cache in
* sync here lets a switch-back hydrate from a FRESH cache.
*
* Skipped in two cases:
* - `useFetchMessages` onData — SWR already holds that exact value, so
* re-writing it would double the IndexedDB persist on every fetch.
* - while the context is streaming — `internal_dispatchMessage` bridges every
* token here via `onMessagesChange`, and a write-through per token would
* thrash. `agent_runtime_end` clears the running flag *before* its final
* `replaceMessages`, so the settled snapshot still writes through.
*/
#writeThroughMessageCache = (
ctx: MessageMapKeyInput,
messagesKey: string,
messages: UIChatMessage[],
action?: string,
): void => {
if (action === 'useFetchMessages') return;
if (operationSelectors.isAgentRuntimeRunningByContext(ctx)(this.#get())) return;
// Match every `message:list` entry whose context resolves to the same bucket
// (any page-size / version / workspace-augmented variant). `revalidate: false`
// seeds the cache without firing a network request.
void mutate(
(key) => {
if (!Array.isArray(key) || key[0] !== messageKeys.list.root) return false;
const keyCtx = key[1] as ConversationContext | undefined;
return !!keyCtx && messageMapKey(keyCtx) === messagesKey;
},
messages,
{ revalidate: false },
);
};
useFetchMessages = (