fix: prevent config overwrite, session history race, and streaming message loss (#663)

Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lingxuan Zuo
2026-03-25 21:11:20 +08:00
committed by GitHub
Unverified
parent 8c3a6a5f7a
commit 83858fdf73
7 changed files with 215 additions and 5 deletions

View File

@@ -45,7 +45,16 @@ export function subscribeHostEvent<T = unknown>(
const listener = (payload: unknown) => {
handler(payload as T);
};
ipc.on(ipcChannel, listener);
// preload's `on()` wraps the callback in an internal subscription function
// and returns a cleanup function that removes that exact wrapper. We MUST
// use the returned cleanup rather than calling `off(channel, listener)`,
// because `listener` !== the internal wrapper and removeListener would be
// a no-op, leaking the subscription.
const unsubscribe = ipc.on(ipcChannel, listener);
if (typeof unsubscribe === 'function') {
return unsubscribe;
}
// Fallback for environments where on() doesn't return cleanup
return () => {
ipc.off(ipcChannel, listener);
};

View File

@@ -1141,6 +1141,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
switchSession: (key: string) => {
if (key === get().currentSessionKey) return;
// Stop any background polling for the old session before switching.
// This prevents the poll timer from firing after the switch and loading
// the wrong session's history into the new session's view.
clearHistoryPoll();
set((s) => buildSessionSwitchPatch(s, key));
get().loadHistory();
},
@@ -1303,6 +1307,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
const loadPromise = (async () => {
const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
// Guard: if the user switched sessions while this async load was in
// flight, discard the result to prevent overwriting the new session's
// messages with stale data from the old session.
if (get().currentSessionKey !== currentSessionKey) return;
// Before filtering: attach images/files from tool_result messages to the next assistant message
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role));

View File

@@ -48,6 +48,22 @@ export function handleRuntimeEventState(
if (event.message && typeof event.message === 'object') {
const msgRole = (event.message as RawMessage).role;
if (isToolResultRole(msgRole)) return s.streamingMessage;
// During multi-model fallback the Gateway may emit a delta with an
// empty or role-only message (e.g. `{}` or `{ role: 'assistant' }`)
// to signal a model switch. Accepting such a value would silently
// discard all content accumulated so far in streamingMessage.
// Only replace when the incoming message carries actual payload.
const msgObj = event.message as RawMessage;
// During multi-model fallback the Gateway may emit an empty or
// role-only delta (e.g. `{}` or `{ role: 'assistant' }`) to signal
// a model switch. If we already have accumulated streaming content,
// accepting such a message would silently discard it. Only guard
// when there IS existing content to protect; when streamingMessage
// is still null, let any delta through so the UI can start showing
// the typing indicator immediately.
if (s.streamingMessage && msgObj.content === undefined) {
return s.streamingMessage;
}
}
return event.message ?? s.streamingMessage;
})(),