agent-ecosystem/test/renderer/store/teamSliceContextRace.test.ts

722 lines
23 KiB
TypeScript

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;
leadSessionId?: string;
sessionHistory?: 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<string, never>;
};
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<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return { promise, resolve, reject };
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
function createSliceStore() {
return create<AppState>()((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<TeamSummaryLike[]>();
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<TeamSummaryLike[]>();
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('preserves team list references when a refresh returns unchanged teams', async () => {
const store = createSliceStore();
const team = {
teamName: 'atlas-hq-15',
displayName: 'Atlas HQ',
projectPath: '/repo',
leadSessionId: 'lead-session',
sessionHistory: ['previous-session'],
};
apiMock.teams.list.mockResolvedValueOnce([team]).mockResolvedValueOnce([{ ...team }]);
await store.getState().fetchTeams();
const firstTeams = store.getState().teams;
const firstTeamByName = store.getState().teamByName;
const firstTeamBySessionId = store.getState().teamBySessionId;
await store.getState().fetchTeams();
expect(store.getState().teams).toBe(firstTeams);
expect(store.getState().teamByName).toBe(firstTeamByName);
expect(store.getState().teamBySessionId).toBe(firstTeamBySessionId);
expect(store.getState().teamBySessionId['lead-session']).toBe(firstTeams[0]);
expect(store.getState().teamBySessionId['previous-session']).toBe(firstTeams[0]);
});
it('reruns a pending global task refresh for the current context instead of applying stale tasks', async () => {
const store = createSliceStore();
const localTasks = deferred<GlobalTaskLike[]>();
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('coalesces concurrent initial global task refreshes for the same context', async () => {
const store = createSliceStore();
const initialTasks = deferred<GlobalTaskLike[]>();
apiMock.teams.getAllTasks.mockReturnValueOnce(initialTasks.promise);
const firstFetch = store.getState().fetchAllTasks();
const secondFetch = store.getState().fetchAllTasks();
initialTasks.resolve([
{
id: 'initial-task',
subject: 'Initial task',
status: 'todo',
teamName: 'initial-team',
teamDisplayName: 'Initial Team',
projectPath: '/initial/project',
comments: [],
},
]);
await Promise.all([firstFetch, secondFetch]);
expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(1);
expect(store.getState().globalTasks).toEqual([
expect.objectContaining({ id: 'initial-task', teamName: 'initial-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<GlobalTaskLike[]>();
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<CrossTeamTargetLike[]>();
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<CrossTeamTargetLike[]>();
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<TeamSnapshotLike>();
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<TeamSnapshotLike>();
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<TeamSnapshotLike>();
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<typeof memberSpawnSnapshot>();
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<never>();
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<typeof runtimeSnapshot>();
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<TeamSnapshotLike>();
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<string, never>;
}>();
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);
});
});