import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { create } from 'zustand'; import { __getTeamScopedTransientStateForTests, __resetTeamSliceModuleStateForTests, createTeamSlice, } from '../../../src/renderer/store/slices/teamSlice'; import { invalidateTeamLocalStateEpoch } from '../../../src/renderer/store/team/teamLocalStateEpoch'; import { invalidateContextScopedRequestEpoch } from '../../../src/renderer/store/utils/contextScopedRequestEpoch'; import type { AppState } from '../../../src/renderer/store/types'; const apiMock = vi.hoisted(() => ({ teams: { list: vi.fn(), getAllTasks: vi.fn(), getData: vi.fn(), getMessagesPage: vi.fn(), getMemberActivityMeta: vi.fn(), getMemberSpawnStatuses: vi.fn(), getTeamAgentRuntime: vi.fn(), getTaskChangePresence: vi.fn(), showMessageNotification: vi.fn(async () => undefined), }, review: { invalidateTaskChangeSummaries: vi.fn(async () => undefined), }, crossTeam: { listTargets: vi.fn(), }, })); interface TeamSummaryLike { teamName: string; displayName: string; projectPath: string; } interface GlobalTaskLike { id: string; subject: string; status: string; teamName: string; teamDisplayName: string; projectPath: string; comments: []; } interface TeamSnapshotLike { teamName: string; config: { name: string; projectPath: string; }; tasks: Array<{ id: string; changePresence?: string; }>; members: []; kanbanState: { teamName: string; reviewers: []; tasks: Record; }; processes: []; } interface CrossTeamTargetLike { teamName: string; displayName: string; } const teamSnapshot = ( teamName: string, projectPath: string, tasks: TeamSnapshotLike['tasks'] = [] ): TeamSnapshotLike => ({ teamName, config: { name: teamName, projectPath, }, tasks, members: [], kanbanState: { teamName, reviewers: [], tasks: {}, }, processes: [], }); const memberSpawnSnapshot = { runId: 'runtime-run', statuses: { lead: { status: 'online', launchState: 'confirmed_alive', }, }, }; const runtimeSnapshot = { teamName: 'shared-team', updatedAt: '2026-03-12T10:00:00.000Z', runId: 'runtime-run', members: { lead: { memberName: 'lead', alive: true, restartable: true, updatedAt: '2026-03-12T10:00:00.000Z', }, }, }; vi.mock('@renderer/api', () => ({ api: apiMock, })); function deferred(): { promise: Promise; resolve: (value: T) => void; reject: (reason?: unknown) => void; } { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((innerResolve, innerReject) => { resolve = innerResolve; reject = innerReject; }); return { promise, resolve, reject }; } async function flushMicrotasks(): Promise { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); } function createSliceStore() { return create()((set, get, store) => ({ ...createTeamSlice(set as never, get as never, store as never), activeContextId: 'local', appConfig: null, paneLayout: { focusedPaneId: 'pane-default', panes: [ { id: 'pane-default', widthFraction: 1, tabs: [], activeTabId: null, }, ], }, openTab: vi.fn(), setActiveTab: vi.fn(), updateTabLabel: vi.fn(), getAllPaneTabs: vi.fn(() => []), warmTaskChangeSummaries: vi.fn(async () => undefined), invalidateTaskChangePresence: vi.fn(), projects: [], repositoryGroups: [], selectedProjectId: null, selectedWorktreeId: null, fetchSessionsInitial: vi.fn(async () => undefined), }) as unknown as AppState ); } describe('team slice context races', () => { beforeEach(() => { __resetTeamSliceModuleStateForTests(); apiMock.teams.list.mockReset(); apiMock.teams.getAllTasks.mockReset(); apiMock.teams.getData.mockReset(); apiMock.teams.getMessagesPage.mockReset(); apiMock.teams.getMemberActivityMeta.mockReset(); apiMock.teams.getMemberSpawnStatuses.mockReset(); apiMock.teams.getTeamAgentRuntime.mockReset(); apiMock.teams.getTaskChangePresence.mockReset(); apiMock.teams.showMessageNotification.mockClear(); apiMock.review.invalidateTaskChangeSummaries.mockClear(); apiMock.crossTeam.listTargets.mockReset(); }); afterEach(() => { __resetTeamSliceModuleStateForTests(); vi.restoreAllMocks(); }); it('ignores a team list response loaded for a previous context', async () => { const store = createSliceStore(); const localList = deferred(); apiMock.teams.list.mockReturnValueOnce(localList.promise); const fetchPromise = store.getState().fetchTeams(); expect(store.getState().teamsLoading).toBe(true); store.setState({ activeContextId: 'ssh-dev', teams: [], teamByName: {}, teamBySessionId: {}, teamsLoading: false, }); localList.resolve([ { teamName: 'local-team', displayName: 'Local Team', projectPath: '/local/project', }, ]); await fetchPromise; expect(store.getState().teams).toEqual([]); expect(store.getState().teamByName).toEqual({}); expect(store.getState().teamsLoading).toBe(false); }); it('ignores a team list response loaded before a context epoch reset with the same context id', async () => { const store = createSliceStore(); const localList = deferred(); apiMock.teams.list.mockReturnValueOnce(localList.promise); const fetchPromise = store.getState().fetchTeams(); expect(store.getState().teamsLoading).toBe(true); invalidateContextScopedRequestEpoch(); store.setState({ activeContextId: 'local', teams: [], teamByName: {}, teamBySessionId: {}, teamsLoading: false, }); localList.resolve([ { teamName: 'old-local-team', displayName: 'Old Local Team', projectPath: '/old-local/project', }, ]); await fetchPromise; expect(store.getState().teams).toEqual([]); expect(store.getState().teamByName).toEqual({}); expect(store.getState().teamsLoading).toBe(false); }); it('reruns a pending global task refresh for the current context instead of applying stale tasks', async () => { const store = createSliceStore(); const localTasks = deferred(); apiMock.teams.getAllTasks.mockReturnValueOnce(localTasks.promise).mockResolvedValueOnce([ { id: 'ssh-task', subject: 'SSH task', status: 'todo', teamName: 'ssh-team', teamDisplayName: 'SSH Team', projectPath: '/ssh/project', comments: [], }, ]); const firstFetch = store.getState().fetchAllTasks(); expect(store.getState().globalTasksLoading).toBe(true); store.setState({ activeContextId: 'ssh-dev', globalTasks: [], globalTasksLoading: false, globalTasksInitialized: false, }); const secondFetch = store.getState().fetchAllTasks(); localTasks.resolve([ { id: 'local-task', subject: 'Local task', status: 'todo', teamName: 'local-team', teamDisplayName: 'Local Team', projectPath: '/local/project', comments: [], }, ]); await Promise.all([firstFetch, secondFetch]); expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(2); expect(store.getState().globalTasks).toEqual([ expect.objectContaining({ id: 'ssh-task', teamName: 'ssh-team' }), ]); expect(store.getState().globalTasksInitialized).toBe(true); expect(store.getState().globalTasksLoading).toBe(false); }); it('ignores global tasks loaded before a context epoch reset with the same context id', async () => { const store = createSliceStore(); const localTasks = deferred(); apiMock.teams.getAllTasks.mockReturnValueOnce(localTasks.promise); const fetchPromise = store.getState().fetchAllTasks(); expect(store.getState().globalTasksLoading).toBe(true); invalidateContextScopedRequestEpoch(); store.setState({ activeContextId: 'local', globalTasks: [], globalTasksLoading: false, globalTasksInitialized: false, }); localTasks.resolve([ { id: 'old-local-task', subject: 'Old local task', status: 'todo', teamName: 'old-local-team', teamDisplayName: 'Old Local Team', projectPath: '/old-local/project', comments: [], }, ]); await fetchPromise; expect(store.getState().globalTasks).toEqual([]); expect(store.getState().globalTasksInitialized).toBe(false); expect(store.getState().globalTasksLoading).toBe(false); }); it('ignores cross-team targets loaded for a previous context', async () => { const store = createSliceStore(); const localTargets = deferred(); apiMock.crossTeam.listTargets.mockReturnValueOnce(localTargets.promise); const fetchPromise = store.getState().fetchCrossTeamTargets(); expect(store.getState().crossTeamTargetsLoading).toBe(true); store.setState({ activeContextId: 'ssh-dev', crossTeamTargets: [], crossTeamTargetsLoading: false, }); localTargets.resolve([ { teamName: 'local-target', displayName: 'Local Target', }, ]); await fetchPromise; expect(store.getState().crossTeamTargets).toEqual([]); expect(store.getState().crossTeamTargetsLoading).toBe(false); }); it('ignores cross-team targets loaded before a context epoch reset with the same context id', async () => { const store = createSliceStore(); const localTargets = deferred(); apiMock.crossTeam.listTargets.mockReturnValueOnce(localTargets.promise); const fetchPromise = store.getState().fetchCrossTeamTargets(); expect(store.getState().crossTeamTargetsLoading).toBe(true); invalidateContextScopedRequestEpoch(); store.setState({ activeContextId: 'local', crossTeamTargets: [], crossTeamTargetsLoading: false, }); localTargets.resolve([ { teamName: 'old-local-target', displayName: 'Old Local Target', }, ]); await fetchPromise; expect(store.getState().crossTeamTargets).toEqual([]); expect(store.getState().crossTeamTargetsLoading).toBe(false); }); it('resolves true after a successful cross-team targets fetch', async () => { const store = createSliceStore(); apiMock.crossTeam.listTargets.mockResolvedValueOnce([ { teamName: 'peer', displayName: 'Peer' }, ]); const ok = await store.getState().fetchCrossTeamTargets(); expect(ok).toBe(true); expect(store.getState().crossTeamTargets).toEqual([{ teamName: 'peer', displayName: 'Peer' }]); }); it('resolves false when the cross-team targets fetch fails so the composer can retry', async () => { const store = createSliceStore(); apiMock.crossTeam.listTargets.mockRejectedValueOnce(new Error('boom')); const ok = await store.getState().fetchCrossTeamTargets(); expect(ok).toBe(false); expect(store.getState().crossTeamTargets).toEqual([]); expect(store.getState().crossTeamTargetsLoading).toBe(false); }); it('ignores selected team data loaded for a previous context', async () => { const store = createSliceStore(); const localData = deferred(); apiMock.teams.getData.mockReturnValueOnce(localData.promise); const selectPromise = store.getState().selectTeam('shared-team'); expect(store.getState().selectedTeamName).toBe('shared-team'); store.setState({ activeContextId: 'ssh-dev', selectedTeamName: null, selectedTeamData: null, selectedTeamLoading: false, teamDataCacheByName: {}, }); localData.resolve(teamSnapshot('shared-team', '/local/project')); await selectPromise; expect(store.getState().selectedTeamName).toBeNull(); expect(store.getState().selectedTeamData).toBeNull(); expect(store.getState().teamDataCacheByName).toEqual({}); }); it('ignores selected team data loaded before a context epoch reset with the same context id', async () => { const store = createSliceStore(); const localData = deferred(); apiMock.teams.getData.mockReturnValueOnce(localData.promise); const selectPromise = store.getState().selectTeam('shared-team'); expect(store.getState().selectedTeamName).toBe('shared-team'); invalidateContextScopedRequestEpoch(); store.setState({ activeContextId: 'local', selectedTeamName: null, selectedTeamData: null, selectedTeamLoading: false, teamDataCacheByName: {}, }); localData.resolve(teamSnapshot('shared-team', '/old-local/project')); await selectPromise; expect(store.getState().selectedTeamName).toBeNull(); expect(store.getState().selectedTeamData).toBeNull(); expect(store.getState().teamDataCacheByName).toEqual({}); }); it('does not let a stale silent team refresh overwrite the current context cache', async () => { const store = createSliceStore(); const sshData = teamSnapshot('shared-team', '/ssh/project'); const localData = deferred(); apiMock.teams.getData.mockReturnValueOnce(localData.promise); const refreshPromise = store.getState().refreshTeamData('shared-team'); store.setState({ activeContextId: 'ssh-dev', teamDataCacheByName: { 'shared-team': sshData, }, } as never); localData.resolve(teamSnapshot('shared-team', '/local/project')); await refreshPromise; expect(store.getState().teamDataCacheByName['shared-team']).toBe(sshData); }); it('ignores message head pages loaded for a previous context', async () => { const store = createSliceStore(); const localMessages = deferred<{ messages: []; feedRevision: string; nextCursor: null; hasMore: false; }>(); apiMock.teams.getMessagesPage.mockReturnValueOnce(localMessages.promise); const refreshPromise = store.getState().refreshTeamMessagesHead('shared-team'); expect(store.getState().teamMessagesByName['shared-team']).toBeDefined(); store.setState({ activeContextId: 'ssh-dev', teamMessagesByName: {}, }); localMessages.resolve({ messages: [], feedRevision: 'local-feed', nextCursor: null, hasMore: false, }); await refreshPromise; expect(store.getState().teamMessagesByName).toEqual({}); }); it('ignores member spawn statuses loaded before a same-context team reset', async () => { const store = createSliceStore(); const localStatuses = deferred(); apiMock.teams.getMemberSpawnStatuses.mockReturnValueOnce(localStatuses.promise); const refreshPromise = store.getState().fetchMemberSpawnStatuses('shared-team'); invalidateTeamLocalStateEpoch('shared-team'); localStatuses.resolve(memberSpawnSnapshot); await refreshPromise; expect(store.getState().memberSpawnStatusesByTeam).toEqual({}); expect(store.getState().memberSpawnSnapshotsByTeam).toEqual({}); expect(store.getState().currentRuntimeRunIdByTeam).toEqual({}); }); it('does not let stale member spawn IPC failures poison the next team scope', async () => { const store = createSliceStore(); const staleFailure = deferred(); apiMock.teams.getMemberSpawnStatuses.mockReturnValueOnce(staleFailure.promise); const refreshPromise = store.getState().fetchMemberSpawnStatuses('shared-team'); invalidateTeamLocalStateEpoch('shared-team'); staleFailure.reject(new Error("No handler registered for 'team:memberSpawnStatuses'")); await refreshPromise; expect( __getTeamScopedTransientStateForTests('shared-team').hasMemberSpawnStatusesIpcBackoff ).toBe(false); }); it('ignores agent runtime snapshots loaded before a same-context team reset', async () => { const store = createSliceStore(); const localRuntime = deferred(); apiMock.teams.getTeamAgentRuntime.mockReturnValueOnce(localRuntime.promise); const refreshPromise = store.getState().fetchTeamAgentRuntime('shared-team'); invalidateTeamLocalStateEpoch('shared-team'); localRuntime.resolve(runtimeSnapshot); await refreshPromise; expect(store.getState().teamAgentRuntimeByTeam).toEqual({}); }); it('ignores change presence loaded before a same-context team reset', async () => { const store = createSliceStore(); const staleData = teamSnapshot('shared-team', '/local/project', [ { id: 'task-1', changePresence: 'unknown' }, ]); const localPresence = deferred<{ 'task-1': 'has_changes' }>(); apiMock.teams.getTaskChangePresence.mockReturnValueOnce(localPresence.promise); store.setState({ selectedTeamName: 'shared-team', selectedTeamData: staleData, teamDataCacheByName: { 'shared-team': staleData, }, } as never); const refreshPromise = store.getState().refreshTeamChangePresence('shared-team'); invalidateTeamLocalStateEpoch('shared-team'); localPresence.resolve({ 'task-1': 'has_changes' }); await refreshPromise; expect(store.getState().selectedTeamData).toBe(staleData); expect(store.getState().teamDataCacheByName['shared-team']).toBe(staleData); }); it('does not rerun pending full team data refreshes from a stale scope', async () => { const store = createSliceStore(); const localData = deferred(); apiMock.teams.getData .mockReturnValueOnce(localData.promise) .mockResolvedValueOnce(teamSnapshot('shared-team', '/unexpected/project')); const firstRefresh = store.getState().refreshTeamData('shared-team', { withDedup: true }); const secondRefresh = store.getState().refreshTeamData('shared-team', { withDedup: true }); invalidateTeamLocalStateEpoch('shared-team'); store.setState({ teamDataCacheByName: {} }); localData.resolve(teamSnapshot('shared-team', '/local/project')); await Promise.all([firstRefresh, secondRefresh]); await flushMicrotasks(); expect(apiMock.teams.getData).toHaveBeenCalledTimes(1); }); it('does not rerun pending message head refreshes from a stale scope', async () => { const store = createSliceStore(); const localMessages = deferred<{ messages: []; feedRevision: string; nextCursor: null; hasMore: false; }>(); apiMock.teams.getMessagesPage.mockReturnValueOnce(localMessages.promise).mockResolvedValueOnce({ messages: [], feedRevision: 'unexpected-feed', nextCursor: null, hasMore: false, }); const firstRefresh = store.getState().refreshTeamMessagesHead('shared-team'); const secondRefresh = store.getState().refreshTeamMessagesHead('shared-team'); invalidateTeamLocalStateEpoch('shared-team'); store.setState({ teamMessagesByName: {} }); localMessages.resolve({ messages: [], feedRevision: 'local-feed', nextCursor: null, hasMore: false, }); await Promise.all([firstRefresh, secondRefresh]); await flushMicrotasks(); expect(apiMock.teams.getMessagesPage).toHaveBeenCalledTimes(1); }); it('does not rerun pending member activity meta refreshes from a stale scope', async () => { const store = createSliceStore(); const localMeta = deferred<{ teamName: string; computedAt: string; feedRevision: string; members: Record; }>(); apiMock.teams.getMemberActivityMeta.mockReturnValueOnce(localMeta.promise).mockResolvedValueOnce({ teamName: 'shared-team', computedAt: '2026-03-12T10:00:01.000Z', feedRevision: 'unexpected-feed', members: {}, }); store.setState({ teamMessagesByName: { 'shared-team': { canonicalMessages: [], optimisticMessages: [], feedRevision: 'feed-1', nextCursor: null, hasMore: false, lastFetchedAt: 0, loadingHead: false, loadingOlder: false, headHydrated: true, }, }, } as never); const firstRefresh = store.getState().refreshMemberActivityMeta('shared-team'); const secondRefresh = store.getState().refreshMemberActivityMeta('shared-team'); invalidateTeamLocalStateEpoch('shared-team'); store.setState({ teamMessagesByName: {}, memberActivityMetaByTeam: {} }); localMeta.resolve({ teamName: 'shared-team', computedAt: '2026-03-12T10:00:00.000Z', feedRevision: 'feed-1', members: {}, }); await Promise.all([firstRefresh, secondRefresh]); await flushMicrotasks(); expect(apiMock.teams.getMemberActivityMeta).toHaveBeenCalledTimes(1); }); });