fix(stage-ui): preserve chat state across Electron windows (#2347)

This commit is contained in:
Neko
2026-08-24 02:05:57 +08:00
committed by GitHub
parent 0229c4f1d7
commit d0bf3eb286
4 changed files with 320 additions and 47 deletions
+3 -2
View File
@@ -225,7 +225,8 @@ as a first language.
- `state: true` sends a full-store proposal after each local mutation. Keep transient and high-frequency state in an unsynchronized store.
- State, action arguments, and action results must support `structuredClone`.
- Keep computed values, query status, runtime clients, controllers, pending promises, and component state outside synchronized state.
- Remote snapshots run local Vue watchers. Never let a watcher on synchronized state write synchronized state or call a synchronized action.
- Remote snapshots run local Vue watchers. A watcher on synchronized state must not write synchronized state directly.
- A watcher can call a synchronized action to enforce a leader-owned invariant. The watcher must await the action. The action must be idempotent because each renderer can observe the same snapshot.
- Enforce cross-field invariants inside explicit actions before the state commit. Do not repair replicated state with a watcher.
- Every returned function in a setup store is a Pinia action. Use computed values or pure helpers for read-only projections.
- List only leader-owned side-effecting actions under `synced.actions`. These actions must be asynchronous, and callers must await them.
@@ -233,7 +234,7 @@ as a first language.
- Keep synchronization and persistence as separate boundaries. Give persisted synchronized state one explicit persistence owner.
- Do not add bidirectional persistence composables or storage-event listeners to synchronized state. Use explicit persistence commands.
- Set the leadership mode explicitly for every Electron renderer. Utility and minimal windows must use `follower-only`.
- Add a multi-window regression test for synchronization changes. One remote snapshot must not produce another mutation or action.
- Add a multi-window regression test for synchronization changes. A remote snapshot must not produce a local synchronized-state proposal. If a watcher calls a synchronized action, verify that repeated calls converge without repeated side effects.
### Readability Refactors
@@ -0,0 +1,190 @@
import type { LeadershipMode, SyncedPiniaRuntime } from 'pinia-plugin-synced'
import type { ChatSessionMeta } from '../../types/chat-session'
import { createPinia, defineStore, disposePinia, setActivePinia } from 'pinia'
import { createSyncedPiniaPlugin } from 'pinia-plugin-synced'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, ref } from 'vue'
const useTestAuthStore = defineStore('auth', () => {
const userId = ref('local')
const token = ref<string | null>(null)
return { userId, token }
}, {
synced: { state: true },
})
const useTestAiriCardStore = defineStore('airi-card', () => {
const activeCardId = ref('default')
const systemPrompt = ref('')
return { activeCardId, systemPrompt }
})
vi.doMock('../auth', () => {
return {
useAuthStore: useTestAuthStore,
}
})
vi.doMock('../modules/airi-card', () => {
return {
useAiriCardStore: useTestAiriCardStore,
}
})
vi.mock('../../database/repos/chat-sessions.repo', () => ({
chatSessionsRepo: {
addTombstone: vi.fn().mockResolvedValue(undefined),
deleteSession: vi.fn().mockResolvedValue(undefined),
dequeueOutbox: vi.fn().mockResolvedValue(undefined),
dropOutboxForSession: vi.fn().mockResolvedValue(undefined),
enqueueOutbox: vi.fn().mockResolvedValue(undefined),
getIndex: vi.fn().mockResolvedValue(null),
getOutbox: vi.fn().mockResolvedValue([]),
getSession: vi.fn().mockResolvedValue(null),
getTombstones: vi.fn().mockResolvedValue([]),
removeTombstones: vi.fn().mockResolvedValue(undefined),
saveIndex: vi.fn().mockResolvedValue(undefined),
saveSession: vi.fn().mockResolvedValue(undefined),
updateOutboxEntries: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('../../libs/analytics', () => ({
captureAnalyticsEvent: vi.fn(),
}))
vi.mock('../../libs/auth-fetch', () => ({
authedFetch: vi.fn(),
}))
vi.mock('../../libs/server', () => ({
SERVER_URL: 'http://test',
}))
vi.mock('../../libs/chat-sync', () => ({
applyCreateActions: vi.fn().mockResolvedValue([]),
createCloudChatMapper: () => ({
deleteChat: vi.fn().mockResolvedValue(undefined),
listChats: vi.fn().mockResolvedValue([]),
}),
createChatWsClient: () => ({
connect: vi.fn(),
destroy: vi.fn(),
disconnect: vi.fn(),
onNewMessages: () => () => {},
onStatusChange: () => () => {},
pullMessages: vi.fn().mockResolvedValue({ messages: [], seq: 0 }),
sendMessages: vi.fn().mockResolvedValue({ ok: true }),
status: () => 'idle',
}),
extractMessageText: () => '',
isCloudSyncableMessage: () => false,
mergeCloudMessagesIntoLocal: () => ({ dirty: false, messages: [], maxSeq: 0 }),
reconcileLocalAndRemote: () => ({ adopt: [], claim: [], create: [] }),
}))
const { useChatSessionStore } = await import('./session-store')
const syncedContexts: Array<{
pinia: ReturnType<typeof createPinia>
runtime: SyncedPiniaRuntime
}> = []
function createSyncedContext(namespace: string, leadership: LeadershipMode) {
const pinia = createPinia()
const runtime = createSyncedPiniaPlugin({
callTimeout: 1000,
leadership,
namespace,
})
pinia.use(runtime.plugin)
createApp({}).use(pinia)
syncedContexts.push({ pinia, runtime })
return { pinia, runtime }
}
afterEach(() => {
for (const context of syncedContexts.splice(0)) {
context.runtime.dispose()
disposePinia(context.pinia)
}
})
describe('chat session synchronization', () => {
it('keeps the leader chat snapshot when new followers receive the auth identity', async () => {
// ROOT CAUSE:
//
// A new settings renderer received the synchronized auth identity after
// its chat-session store was created. Its local userId watcher cleared the
// synchronized chat state and proposed that empty snapshot to the leader.
//
// The follower routes its observed auth transition to the synchronized
// identity action. The leader keeps its matching snapshot unchanged.
const namespace = `chat-session:${crypto.randomUUID()}`
const leaderContext = createSyncedContext(namespace, 'leader-only')
await vi.waitFor(() => expect(leaderContext.runtime.isLeader()).toBe(true))
setActivePinia(leaderContext.pinia)
const leaderAuthStore = useTestAuthStore()
leaderAuthStore.userId = 'cloud-user'
const leaderChatStore = useChatSessionStore()
const session: ChatSessionMeta = {
sessionId: 'session-a',
userId: 'cloud-user',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
leaderChatStore.$patch({
index: {
userId: 'cloud-user',
characters: {
default: {
activeSessionId: 'session-a',
sessions: { 'session-a': session },
},
},
},
sessionMessages: {
'session-a': [{ id: 'message-a', role: 'user', content: 'Keep this message' }],
},
sessionMetas: { 'session-a': session },
})
let leaderIdentityActions = 0
let leaderMutations = 0
leaderChatStore.$onAction(({ name }) => {
if (name === 'activateCurrentUser')
leaderIdentityActions++
})
leaderChatStore.$subscribe(() => leaderMutations++)
const followerContext = createSyncedContext(namespace, 'follower-only')
setActivePinia(followerContext.pinia)
const followerChatStore = useChatSessionStore()
const followerAuthStore = useTestAuthStore()
await vi.waitFor(() => expect(followerContext.runtime.getLeaderId()).toBe(leaderContext.runtime.participantId))
await vi.waitFor(() => expect(followerAuthStore.userId).toBe('cloud-user'))
await vi.waitFor(() => expect(followerChatStore.sessionMessages['session-a']).toHaveLength(1))
const secondFollowerContext = createSyncedContext(namespace, 'follower-only')
setActivePinia(secondFollowerContext.pinia)
const secondFollowerChatStore = useChatSessionStore()
const secondFollowerAuthStore = useTestAuthStore()
await vi.waitFor(() => expect(secondFollowerContext.runtime.getLeaderId()).toBe(leaderContext.runtime.participantId))
await vi.waitFor(() => expect(secondFollowerAuthStore.userId).toBe('cloud-user'))
await vi.waitFor(() => expect(secondFollowerChatStore.sessionMessages['session-a']).toHaveLength(1))
await Promise.resolve()
expect(leaderChatStore.sessionMessages['session-a']?.[0]?.id).toBe('message-a')
expect(followerChatStore.sessionMessages['session-a']?.[0]?.id).toBe('message-a')
expect(leaderChatStore.index?.userId).toBe('cloud-user')
expect(followerChatStore.index?.userId).toBe('cloud-user')
expect(secondFollowerChatStore.sessionMessages['session-a']?.[0]?.id).toBe('message-a')
expect(leaderIdentityActions).toBe(2)
expect(leaderMutations).toBe(0)
})
})
@@ -147,15 +147,8 @@ describe('chat-session-store · user swap during in-flight ensureActiveSessionFo
// ROOT CAUSE:
//
// ensureActiveSessionForCharacter caches `ensureActivePromise` for singleflight
// and the IIFE captures `currentUserId` at start. When `userId` flips A → B
// mid-flight:
// 1. The userId watcher calls clearInMemoryState (resets sessionMetas /
// index / activeSessionId), but does NOT reset `ensureActivePromise`.
// 2. A's IIFE eventually resumes after its awaited IDB read completes and
// writes A's session record back into the now-empty B state — leak.
// 3. Any subsequent ensureActiveSessionForCharacter call (e.g. from the
// [userId, activeCardId] watcher) returns A's stale promise instead of
// starting a fresh hydrate for B — B silently sees no sessions.
// and the IIFE captures `currentUserId` at start. An explicit A → B identity
// transition must invalidate A's in-flight read before it hydrates B.
//
// We fix this by:
// - bumping an `ensureActiveEpoch` and nulling `ensureActivePromise` in
@@ -163,8 +156,7 @@ describe('chat-session-store · user swap during in-flight ensureActiveSessionFo
// - re-checking the captured epoch after each await inside the IIFE,
// - re-checking `sessionMetas[sessionId]` inside `loadSession` so the
// post-IDB write does not resurrect cleared state,
// - triggering a fresh hydrate from the userId watcher itself so the new
// user actually loads.
// - hydrating the new identity only through `activateCurrentUser`.
it('runs a fresh hydrate for the new user and discards the stale write from the old user', async () => {
const aSessionMeta: ChatSessionMeta = {
sessionId: 'sess-A',
@@ -232,7 +224,7 @@ describe('chat-session-store · user swap during in-flight ensureActiveSessionFo
expect(getSessionMock).toHaveBeenCalledWith('sess-A')
expect(resolveASessionGet).toBeDefined()
// Auth swap mid-flight.
// The synchronized auth state changes while A's session read is in flight.
userIdRef.value = 'B'
await nextTick()
await flushMicrotasks()
@@ -243,8 +235,8 @@ describe('chat-session-store · user swap during in-flight ensureActiveSessionFo
await initPromise.catch(() => {})
await flushMicrotasks()
// B's hydrate must have fired — without the fix, the [userId, activeCardId]
// watcher returned the stale A promise and B never loaded.
// B's hydrate must have fired. Without the fix, the stale A promise blocks
// the identity action and B never loads.
expect(getIndexMock).toHaveBeenCalledWith('B')
expect(store.sessionMetas['sess-B']).toBeDefined()
@@ -719,6 +711,57 @@ describe('chat-session-store · active card prompt edits', () => {
})
describe('chat-session-store · synchronized data actions', () => {
it('keeps synchronized session data when a follower receives authenticated user state', async () => {
// ROOT CAUSE:
//
// A new settings window received the synchronized auth user after its
// chat-session store was created. The userId watcher then cleared the
// synchronized session state in that follower. pinia-plugin-synced sent
// the empty full-state proposal to the leader and removed chat messages
// from every window.
//
// The watcher routes the identity transition to an idempotent synchronized
// action. The action keeps state that already belongs to the current user.
const session: ChatSessionMeta = {
sessionId: 'session-a',
userId: 'cloud-user',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
const store = useChatSessionStore()
store.setCloudSyncOwnership(false)
store.applyRemoteSnapshot({
activeSessionId: 'session-a',
sessionMessages: {
'session-a': [{ id: 'message-a', role: 'user', content: 'Keep this message' }],
},
sessionMetas: { 'session-a': session },
index: {
userId: 'cloud-user',
characters: {
default: {
activeSessionId: 'session-a',
sessions: { 'session-a': session },
},
},
},
})
await nextTick()
await flushMicrotasks()
const messageIdsBeforeAuthHydration = store.sessionMessages['session-a'].map(message => message.id)
const metaBeforeAuthHydration = { ...store.sessionMetas['session-a'] }
userIdRef.value = 'cloud-user'
await nextTick()
await flushMicrotasks()
expect(store.sessionMessages['session-a'].map(message => message.id)).toEqual(messageIdsBeforeAuthHydration)
expect(store.sessionMetas['session-a']).toEqual(metaBeforeAuthHydration)
expect(store.activeSessionId).toBe('session-a')
expect(store.index?.userId).toBe('cloud-user')
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3755711151
it('keeps cloud synchronization in the elected leader for Issue #2085', async () => {
// ROOT CAUSE:
@@ -734,7 +777,7 @@ describe('chat-session-store · synchronized data actions', () => {
expect(connectCloudWsMock).not.toHaveBeenCalled()
store.setCloudSyncOwnership(true)
expect(connectCloudWsMock).toHaveBeenCalledTimes(1)
await vi.waitFor(() => expect(connectCloudWsMock).toHaveBeenCalledTimes(1))
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743242525
@@ -752,6 +795,7 @@ describe('chat-session-store · synchronized data actions', () => {
updatedAt: 1,
}
const store = useChatSessionStore()
store.setCloudSyncOwnership(false)
store.$patch({
sessionMessages: { 'session-b': [{ id: 'system', role: 'system', content: 'prompt' }] },
sessionMetas: { 'session-b': session },
@@ -773,6 +817,7 @@ describe('chat-session-store · synchronized data actions', () => {
expect(store.activeSessionId).toBe('session-b')
expect(store.isReady).toBe(true)
expect(getSessionMock).not.toHaveBeenCalled()
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743242529
@@ -1072,11 +1072,38 @@ export const useChatSessionStore = defineStore('chat-session', () => {
index.value = null
activeSessionId.value = ''
cloudSyncReady.value = false
// outbox count reflects the prior user; reset to 0 — the next user's
// refreshOutboxPendingCount fires from initialize() once they hydrate.
// The outbox count belongs to the prior user. The identity action refreshes
// this count after it hydrates the next user.
outboxPendingCount.value = 0
}
function sessionStateMatchesCurrentUser() {
const currentUserId = getCurrentUserId()
const hasOnlyCurrentUserSessions = Object.values(sessionMetas.value)
.every(meta => meta.userId === currentUserId)
return index.value?.userId === currentUserId && hasOnlyCurrentUserSessions
}
/**
* Replaces session state after the synchronized auth identity changes.
* The synchronization plugin routes this action to the elected renderer.
*/
async function activateCurrentUser() {
if (sessionStateMatchesCurrentUser()) {
ensureCloudWsClient()
return
}
teardownCloudWsClient()
clearInMemoryState()
if (!ready.value && !initializing.value)
return
await ensureActiveSessionForCharacter()
await refreshOutboxPendingCount()
ensureCloudWsClient()
}
/**
* Refresh the reactive `outboxPendingCount` from IDB. Called after every
* enqueue / dequeue / drain so UI banners stay in sync with reality.
@@ -1291,12 +1318,17 @@ export const useChatSessionStore = defineStore('chat-session', () => {
}
initializing.value = true
initializePromise = (async () => {
await ensureActiveSessionForCharacter()
if (ownsCloudSync)
await ensureActiveSessionForCharacter()
else
selectWindowSessionFromIndex()
ready.value = true
// Surface any outbox left over from a previous session (closed tab
// mid-send) before the WS even opens. The drain itself runs after
// reconcile completes, but the count is observable immediately.
await refreshOutboxPendingCount()
if (ownsCloudSync)
await refreshOutboxPendingCount()
if (ownsCloudSync)
ensureCloudWsClient()
})()
@@ -1322,6 +1354,17 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|| !!Object.values(index.value?.characters ?? {}).some(character => character.sessions[sessionId])
}
/** Selects the persisted session for this window without changing synchronized session data. */
function selectWindowSessionFromIndex() {
const currentUserId = getCurrentUserId()
if (!index.value || index.value.userId !== currentUserId) {
activeSessionId.value = ''
return
}
activeSessionId.value = getCharacterIndex(getCurrentCharacterId())?.activeSessionId ?? ''
}
const messages = computed<ChatHistoryItem[]>({
get: () => {
if (!activeSessionId.value) {
@@ -1552,41 +1595,34 @@ export const useChatSessionStore = defineStore('chat-session', () => {
// every follower would fan one deletion out into several empty chats.
})
watch([userId, activeCardId], () => {
watch([activeCardId, index], () => {
if (!ready.value)
return
if (!ownsCloudSync) {
selectWindowSessionFromIndex()
return
}
void ensureActiveSessionForCharacter()
})
// Each renderer observes the synchronized identity. Route the transition to
// the leader so followers never write synchronized chat state directly.
watch(userId, async () => {
try {
await useChatSessionStore().activateCurrentUser()
}
catch (error) {
console.error('[chat-session] Failed to activate the current user:', error)
}
})
// Keep the active conversation aligned with edits to the active card. The
// active session id is included because card switching resolves the target
// session asynchronously after the card prompt itself has already changed.
watch([systemPrompt, activeSessionId], refreshActiveSessionSystemMessage)
// Auth toggles drive cloud WS lifecycle independently of activeCardId so
// a card swap inside a single session does not bounce the socket. The
// critical invariant: when the auth user changes, every piece of in-memory
// state from the previous user must be cleared BEFORE the new user's WS
// and reconcile fire. Otherwise the previous user's sessionMetas would
// leak into the new user's drawer, exports, and (worst) into the cloud
// reconcile's `localOwnedMetas` snapshot.
watch(userId, (next) => {
teardownCloudWsClient()
clearInMemoryState()
if (ownsCloudSync && next && next !== 'local') {
ensureCloudWsClient()
}
// Rehydrate for the new user. We trigger here (instead of relying on the
// `[userId, activeCardId]` watcher) because that watcher gates on
// `ready.value` — if the swap happens while initialize() is still
// awaiting the prior user's hydrate, the gated trigger is dropped and
// the new user silently sees no sessions. `clearInMemoryState` already
// bumped `ensureActiveEpoch` and freed the singleflight slot, so this
// call starts a fresh IIFE that runs alongside (and is unaffected by)
// any in-flight stale hydrate.
void ensureActiveSessionForCharacter()
})
return {
isReady,
initialize,
@@ -1623,6 +1659,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
loadSession,
refreshSession,
deleteSession,
activateCurrentUser,
setCloudSyncOwnership,
@@ -1632,7 +1669,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
}
}, {
synced: {
actions: ['createSession', 'deleteMessage', 'importSessions', 'loadSession', 'refreshSession'],
actions: ['activateCurrentUser', 'createSession', 'deleteMessage', 'importSessions', 'loadSession', 'refreshSession'],
state: true,
},
})