import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => ({ onTeamChangeCb: null as | (( event: unknown, data: { type?: string; teamName: string; detail?: string; runId?: string; taskId?: string; taskSignalKind?: 'log' | 'change'; } ) => void) | null, onProvisioningProgressCb: null as | ((event: unknown, data: { runId: string; teamName: string }) => void) | null, })); vi.mock('@renderer/api', () => ({ api: { config: { get: vi.fn(async () => ({ general: { theme: 'dark' }, notifications: { enabled: true, triggers: [] }, })), }, getRepositoryGroups: vi.fn(async () => []), notifications: { onNew: vi.fn(() => () => undefined), onUpdated: vi.fn(() => () => undefined), onClicked: vi.fn(() => () => undefined), get: vi.fn(async () => ({ notifications: [], total: 0, totalCount: 0, unreadCount: 0, hasMore: false, })), }, teams: { setChangePresenceTracking: vi.fn(async () => undefined), setToolActivityTracking: vi.fn(async () => undefined), setTaskLogStreamTracking: vi.fn(async () => undefined), onTeamChange: vi.fn( ( cb: ( event: unknown, data: { teamName: string; type?: string; detail?: string; runId?: string; taskId?: string; taskSignalKind?: 'log' | 'change'; } ) => void ): (() => void) => { hoisted.onTeamChangeCb = cb; return () => { hoisted.onTeamChangeCb = null; }; } ), onProvisioningProgress: vi.fn( (cb: (event: unknown, data: { runId: string; teamName: string }) => void): (() => void) => { hoisted.onProvisioningProgressCb = cb; return () => { hoisted.onProvisioningProgressCb = null; }; } ), getAllTasks: vi.fn(async () => []), list: vi.fn(async () => []), }, schedules: { list: vi.fn(async () => []), onScheduleChange: vi.fn(() => () => undefined), }, }, })); import { api } from '@renderer/api'; import { initializeNotificationListeners, useStore } from '../../../src/renderer/store'; import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice'; import { __resetTeamRefreshFanoutDiagnosticsForTests, getTeamRefreshFanoutSnapshotForTests, summarizeTeamRefreshFanout, type TeamRefreshFanoutSnapshot, } from '../../../src/renderer/store/teamRefreshFanoutDiagnostics'; describe('team change throttling', () => { let cleanup: (() => void) | null = null; beforeEach(async () => { vi.useFakeTimers(); __resetTeamSliceModuleStateForTests(); __resetTeamRefreshFanoutDiagnosticsForTests(); const fetchTeams = vi.fn(async () => undefined); const fetchMemberSpawnStatuses = vi.fn(async () => undefined); const fetchTeamAgentRuntime = vi.fn(async () => undefined); const refreshTeamData = vi.fn(async () => undefined); const refreshTeamMessagesHead = vi.fn(async () => ({ feedChanged: true, headChanged: true, feedRevision: 'rev-1', })); const refreshMemberActivityMeta = vi.fn(async () => undefined); const refreshTeamChangePresence = vi.fn(async () => undefined); useStore.setState({ fetchTeams, fetchMemberSpawnStatuses, fetchTeamAgentRuntime, refreshTeamData, refreshTeamMessagesHead, refreshMemberActivityMeta, refreshTeamChangePresence, selectedTeamName: null, selectedTeamData: null, teamDataCacheByName: {}, provisioningRuns: {}, currentProvisioningRunIdByTeam: {}, currentRuntimeRunIdByTeam: {}, ignoredProvisioningRunIds: {}, ignoredRuntimeRunIds: {}, activeTaskLogActivityByTeam: {}, memberSpawnStatusesByTeam: {}, memberSpawnSnapshotsByTeam: {}, teamAgentRuntimeByTeam: {}, memberActivityMetaByTeam: {}, paneLayout: { focusedPaneId: 'p1', panes: [ { id: 'p1', widthFraction: 1, tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }], activeTabId: 't1', }, ], }, } as never); cleanup = initializeNotificationListeners(); // Flush microtask queue so the sequential init chain completes // before test assertions start (prevents init calls from leaking into spies). await vi.advanceTimersByTimeAsync(0); }); afterEach(() => { cleanup?.(); cleanup = null; __resetTeamSliceModuleStateForTests(); __resetTeamRefreshFanoutDiagnosticsForTests(); window.localStorage.removeItem('team:processLiteFanout'); vi.mocked(console.warn).mockClear(); vi.useRealTimers(); }); it('throttles both team list and detail refresh', async () => { const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); // Fire 3 rapid events hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' }); hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' }); hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' }); // Both are throttled — nothing called synchronously expect(fetchTeamsSpy).not.toHaveBeenCalled(); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); // Detail refresh fires at 800ms await vi.advanceTimersByTimeAsync(799); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); // List refresh fires at 2000ms expect(fetchTeamsSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1200); expect(fetchTeamsSpy).toHaveBeenCalledTimes(1); }); it('does not scan repository groups during centralized startup initialization', async () => { const getRepositoryGroupsSpy = vi.mocked(api.getRepositoryGroups); getRepositoryGroupsSpy.mockClear(); cleanup?.(); cleanup = initializeNotificationListeners(); await vi.advanceTimersByTimeAsync(0); expect(getRepositoryGroupsSpy).not.toHaveBeenCalled(); }); it('defers the initial global task fetch until the startup idle window', async () => { const fetchAllTasksSpy = vi.fn(async () => undefined); useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); cleanup?.(); cleanup = initializeNotificationListeners(); await vi.advanceTimersByTimeAsync(0); expect(fetchAllTasksSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(4_999); expect(fetchAllTasksSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1); }); it('cancels the deferred initial global task fetch during listener cleanup', async () => { const fetchAllTasksSpy = vi.fn(async () => undefined); useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); cleanup?.(); cleanup = initializeNotificationListeners(); await vi.advanceTimersByTimeAsync(0); cleanup(); cleanup = null; await vi.advanceTimersByTimeAsync(30_000); expect(fetchAllTasksSpy).not.toHaveBeenCalled(); }); it('allows next refresh after throttle window passes', async () => { const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); // Second event after throttle window hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); }); it('keeps process events on the existing structural refresh path and records fanout', async () => { const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); const snapshot = getTeamRefreshFanoutSnapshotForTests( 'my-team' ) as TeamRefreshFanoutSnapshot | null; expect( snapshot?.counts['team-change-listener:event:process:refreshTeamData:scheduled'] ).toBe(1); expect(snapshot?.counts['team-change-listener:event:process:refreshTeamData:executed']).toBe( 1 ); }); it('uses process-lite for strict candidates and delays structural reconcile', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses'); const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); await vi.advanceTimersByTimeAsync(500); expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledTimes(1); expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team'); expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledTimes(1); expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledWith('my-team'); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(fetchTeamsSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1999); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(fetchTeamsSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(fetchTeamsSpy).toHaveBeenCalledTimes(1); expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); const summary = summarizeTeamRefreshFanout('my-team'); expect(summary.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ reason: 'dry-run:process-lite:processes-json-visible-runtime-context', operation: 'wouldUseProcessLite', phase: 'skipped', }), expect.objectContaining({ reason: 'event:process-lite:structural-suppressed', operation: 'refreshTeamData', phase: 'skipped', }), expect.objectContaining({ reason: 'event:process-lite', operation: 'fetchMemberSpawnStatuses', phase: 'executed', }), expect.objectContaining({ reason: 'event:process-lite', operation: 'fetchTeamAgentRuntime', phase: 'executed', }), expect.objectContaining({ reason: 'event:process-lite:structural-reconcile', operation: 'refreshTeamData', phase: 'executed', }), expect.objectContaining({ reason: 'event:process-lite:structural-reconcile', operation: 'fetchTeams', phase: 'executed', }), ]) ); }); it('uses process-lite when an active provisioning run exists without current runtime', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentProvisioningRunIdByTeam: { 'my-team': 'run-1' }, provisioningRuns: { 'run-1': { runId: 'run-1', teamName: 'my-team', state: 'spawning', message: 'Spawning', startedAt: '2026-05-03T00:00:00.000Z', updatedAt: '2026-05-03T00:00:00.000Z', }, }, currentRuntimeRunIdByTeam: {}, } as never); const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses'); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); await vi.advanceTimersByTimeAsync(500); expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team'); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(20_000); expect(fetchTeamsSpy).not.toHaveBeenCalled(); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); const summary = summarizeTeamRefreshFanout('my-team'); expect(summary.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ reason: 'event:process-lite:structural-reconcile:suppressed-during-launch', operation: 'refreshTeamData', phase: 'skipped', }), ]) ); }); it('keeps active launch process file events runtime-only before team data hydrates', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: null, currentProvisioningRunIdByTeam: { 'my-team': 'run-1' }, provisioningRuns: { 'run-1': { runId: 'run-1', teamName: 'my-team', state: 'spawning', message: 'Spawning', startedAt: '2026-05-03T00:00:00.000Z', updatedAt: '2026-05-03T00:00:00.000Z', }, }, currentRuntimeRunIdByTeam: {}, } as never); const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses'); const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(799); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team'); expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledWith('my-team'); expect(useStore.getState().selectedTeamData).toBeNull(); expect(useStore.getState().teamDataCacheByName['my-team']).toBeUndefined(); await vi.advanceTimersByTimeAsync(19_200); expect(fetchTeamsSpy).not.toHaveBeenCalled(); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); const summary = summarizeTeamRefreshFanout('my-team'); expect(summary.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ reason: 'dry-run:process-lite:missing-visible-team-data', operation: 'wouldKeepStructuralProcess', phase: 'skipped', }), expect.objectContaining({ reason: 'event:process-lite:structural-suppressed-during-launch', operation: 'refreshTeamData', phase: 'skipped', }), ]) ); }); it('keeps active launch process file events structural when process-lite is disabled', async () => { window.localStorage.setItem('team:processLiteFanout', '0'); useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: null, currentProvisioningRunIdByTeam: { 'my-team': 'run-1' }, provisioningRuns: { 'run-1': { runId: 'run-1', teamName: 'my-team', state: 'spawning', message: 'Spawning', startedAt: '2026-05-03T00:00:00.000Z', updatedAt: '2026-05-03T00:00:00.000Z', }, }, currentRuntimeRunIdByTeam: {}, } as never); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); }); it('suppresses idle-watchdog structural refresh during active launch', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentProvisioningRunIdByTeam: { 'my-team': 'run-1' }, provisioningRuns: { 'run-1': { runId: 'run-1', teamName: 'my-team', state: 'spawning', message: 'Spawning', startedAt: '2026-05-03T00:00:00.000Z', updatedAt: '2026-05-03T00:00:00.000Z', }, }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-1' } ); await vi.advanceTimersByTimeAsync(30_000); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); }); it('keeps idle-watchdog structural refresh available when process-lite is disabled', async () => { window.localStorage.setItem('team:processLiteFanout', '0'); useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentProvisioningRunIdByTeam: { 'my-team': 'run-1' }, provisioningRuns: { 'run-1': { runId: 'run-1', teamName: 'my-team', state: 'spawning', message: 'Spawning', startedAt: '2026-05-03T00:00:00.000Z', updatedAt: '2026-05-03T00:00:00.000Z', }, }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'lead-activity', teamName: 'my-team', detail: 'active', runId: 'run-1' } ); await vi.advanceTimersByTimeAsync(59_999); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); }); it('does not treat terminal or unknown provisioning states as process-lite active', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentProvisioningRunIdByTeam: { 'my-team': 'run-1' }, provisioningRuns: { 'run-1': { runId: 'run-1', teamName: 'my-team', state: 'ready', message: 'Ready', startedAt: '2026-05-03T00:00:00.000Z', updatedAt: '2026-05-03T00:00:00.000Z', }, }, currentRuntimeRunIdByTeam: {}, } as never); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); }); it('keeps unsafe process details structural during active launch', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentProvisioningRunIdByTeam: { 'my-team': 'run-1' }, provisioningRuns: { 'run-1': { runId: 'run-1', teamName: 'my-team', state: 'spawning', message: 'Spawning', startedAt: '2026-05-03T00:00:00.000Z', updatedAt: '2026-05-03T00:00:00.000Z', }, }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses'); hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team', detail: 'failed' }); await vi.advanceTimersByTimeAsync(500); expect(fetchMemberSpawnStatusesSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(300); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); const summary = summarizeTeamRefreshFanout('my-team'); expect(summary.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ reason: 'dry-run:process-lite:unsafe-process-detail', operation: 'wouldKeepStructuralProcess', phase: 'skipped', }), ]) ); }); it('keeps strict process candidates on the structural path when process-lite is disabled', async () => { window.localStorage.setItem('team:processLiteFanout', '0'); useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); expect(fetchTeamAgentRuntimeSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1200); expect(fetchTeamsSpy).toHaveBeenCalledTimes(1); const summary = summarizeTeamRefreshFanout('my-team'); expect(summary.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ reason: 'event:process-lite:disabled', operation: 'wouldKeepStructuralProcess', phase: 'skipped', }), ]) ); }); it('coalesces process-lite structural reconcile until idle or max wait', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); for (let elapsed = 2_000; elapsed <= 14_000; elapsed += 2_000) { await vi.advanceTimersByTimeAsync(2_000); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(fetchTeamsSpy).not.toHaveBeenCalled(); } await vi.advanceTimersByTimeAsync(999); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(fetchTeamsSpy).toHaveBeenCalledTimes(1); expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); }); it('cancels pending process-lite structural reconcile when launch becomes active', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); await vi.advanceTimersByTimeAsync(500); useStore.setState({ currentProvisioningRunIdByTeam: { 'my-team': 'run-2' }, provisioningRuns: { 'run-2': { runId: 'run-2', teamName: 'my-team', state: 'spawning', message: 'Spawning', startedAt: '2026-05-03T00:00:00.000Z', updatedAt: '2026-05-03T00:00:00.000Z', }, }, currentRuntimeRunIdByTeam: { 'my-team': 'run-2' }, } as never); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-2' } ); await vi.advanceTimersByTimeAsync(20_000); expect(fetchTeamsSpy).not.toHaveBeenCalled(); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); const summary = summarizeTeamRefreshFanout('my-team'); expect(summary.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ reason: 'event:process-lite:structural-reconcile:cancelled-during-launch', operation: 'refreshTeamData', phase: 'skipped', }), ]) ); }); it('skips pending process-lite structural reconcile if provisioning becomes active before the timer fires', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-1' } ); await vi.advanceTimersByTimeAsync(500); useStore.setState({ currentProvisioningRunIdByTeam: { 'my-team': 'run-2' }, provisioningRuns: { 'run-2': { runId: 'run-2', teamName: 'my-team', state: 'spawning', message: 'Spawning', startedAt: '2026-05-03T00:00:00.000Z', updatedAt: '2026-05-03T00:00:00.000Z', }, }, currentRuntimeRunIdByTeam: { 'my-team': 'run-2' }, } as never); await vi.advanceTimersByTimeAsync(20_000); expect(fetchTeamsSpy).not.toHaveBeenCalled(); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); const summary = summarizeTeamRefreshFanout('my-team'); expect(summary.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ reason: 'event:process-lite:structural-reconcile:suppressed-during-launch', operation: 'refreshTeamData', phase: 'skipped', }), ]) ); }); it('keeps pending process-lite structural reconcile available when process-lite is disabled before the timer fires', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-1' } ); await vi.advanceTimersByTimeAsync(500); window.localStorage.setItem('team:processLiteFanout', '0'); useStore.setState({ currentProvisioningRunIdByTeam: { 'my-team': 'run-2' }, provisioningRuns: { 'run-2': { runId: 'run-2', teamName: 'my-team', state: 'spawning', message: 'Spawning', startedAt: '2026-05-03T00:00:00.000Z', updatedAt: '2026-05-03T00:00:00.000Z', }, }, currentRuntimeRunIdByTeam: { 'my-team': 'run-2' }, } as never); await vi.advanceTimersByTimeAsync(2_000); expect(fetchTeamsSpy).toHaveBeenCalledTimes(1); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); }); it('cancels pending process-lite reconcile when a normal structural event wins', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); await vi.advanceTimersByTimeAsync(500); hoisted.onTeamChangeCb?.({}, { type: 'task', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(15_000); expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); }); it('does not let process-lite coalescing weaken member-spawn runtime refresh semantics', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const state = useStore.getState(); const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses'); const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); hoisted.onTeamChangeCb?.({}, { type: 'member-spawn', teamName: 'my-team' }); useStore.setState({ paneLayout: { focusedPaneId: 'p1', panes: [ { id: 'p1', widthFraction: 1, tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }], activeTabId: 't2', }, ], }, } as never); await vi.advanceTimersByTimeAsync(500); expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledTimes(1); expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team'); expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledTimes(1); expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledWith('my-team'); }); it('cleans up pending process-lite reconcile timers', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'my-team', detail: 'processes.json' } ); cleanup?.(); cleanup = null; await vi.advanceTimersByTimeAsync(20_000); expect(fetchTeamsSpy).not.toHaveBeenCalled(); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); }); it('records unsafe process details as structural dry-run without changing refresh behavior', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, currentRuntimeRunIdByTeam: { 'my-team': 'run-1' }, } as never); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team', detail: 'cancelled' }); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); const summary = summarizeTeamRefreshFanout('my-team'); expect(summary.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ reason: 'dry-run:process-lite:unsafe-process-detail', operation: 'wouldKeepStructuralProcess', phase: 'skipped', }), ]) ); }); it('keeps hidden process events out of visible detail refresh while recording structural dry-run', async () => { useStore.setState({ paneLayout: { focusedPaneId: 'p1', panes: [ { id: 'p1', widthFraction: 1, tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }], activeTabId: 't1', }, ], }, teamDataCacheByName: { 'other-team': { teamName: 'other-team', config: { name: 'Other Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, }, currentRuntimeRunIdByTeam: { 'other-team': 'run-1' }, } as never); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'process', teamName: 'other-team', detail: 'processes.json' } ); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).not.toHaveBeenCalledWith('other-team', { withDedup: true }); const summary = summarizeTeamRefreshFanout('other-team'); expect(summary.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ reason: 'dry-run:process-lite:hidden-team', operation: 'wouldKeepStructuralProcess', phase: 'skipped', }), ]) ); }); it('keeps task and config events on the existing global task refresh path', async () => { const fetchAllTasksSpy = vi.fn(async () => undefined); useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); hoisted.onTeamChangeCb?.({}, { type: 'task', teamName: 'my-team' }); hoisted.onTeamChangeCb?.({}, { type: 'config', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(500); expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1); const snapshot = getTeamRefreshFanoutSnapshotForTests( 'my-team' ) as TeamRefreshFanoutSnapshot | null; expect(snapshot?.counts['team-change-listener:event:task:fetchAllTasks:scheduled']).toBe(1); expect(snapshot?.counts['team-change-listener:event:config:fetchAllTasks:coalesced']).toBe(1); expect(snapshot?.counts['team-change-listener:event:task:fetchAllTasks:executed']).toBe(1); expect(snapshot?.counts['team-change-listener:event:config:fetchAllTasks:executed']).toBe(1); }); it('slows global task refreshes during active provisioning', async () => { const fetchAllTasksSpy = vi.fn(async () => undefined); useStore.setState({ fetchAllTasks: fetchAllTasksSpy, currentProvisioningRunIdByTeam: { 'my-team': 'run-1' }, provisioningRuns: { 'run-1': { runId: 'run-1', teamName: 'my-team', state: 'spawning', message: 'Spawning', startedAt: '2026-05-03T00:00:00.000Z', updatedAt: '2026-05-03T00:00:00.000Z', }, }, } as never); await vi.advanceTimersByTimeAsync(5000); fetchAllTasksSpy.mockClear(); hoisted.onTeamChangeCb?.({}, { type: 'task', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(4999); expect(fetchAllTasksSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1); }); it('lead-message refreshes message head only, not team list, tasks, or structural detail', async () => { const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead'); const refreshMemberActivityMetaSpy = vi.spyOn(state, 'refreshMemberActivityMeta'); // Emit a lead-message event hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); // Should NOT trigger fetchTeams await vi.advanceTimersByTimeAsync(2100); expect(fetchTeamsSpy).not.toHaveBeenCalled(); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1); expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team'); expect(refreshMemberActivityMetaSpy).toHaveBeenCalledTimes(1); expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('my-team'); }); it('lead-message refreshes visible graph tabs even when the team is not selected', async () => { useStore.setState({ selectedTeamName: 'other-team', selectedTeamData: { teamName: 'other-team', config: { name: 'Other Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, paneLayout: { focusedPaneId: 'p1', panes: [ { id: 'p1', widthFraction: 1, tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }], activeTabId: 'g1', }, ], }, } as never); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team'); }); it('lead-message refreshes hidden teams with an active pending-reply wait state', async () => { useStore.getState().syncTeamPendingReplyRefresh('other-team', 'tab-hidden', true, 60_000); useStore.setState({ paneLayout: { focusedPaneId: 'p1', panes: [ { id: 'p1', widthFraction: 1, tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }], activeTabId: 't1', }, ], }, } as never); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta'); hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' }); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team'); expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team'); }); it('lead-message does not refresh hidden inactive teams without pending replies', async () => { useStore.setState({ paneLayout: { focusedPaneId: 'p1', panes: [ { id: 'p1', widthFraction: 1, tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }], activeTabId: 't1', }, ], }, } as never); const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta'); hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' }); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamMessagesHeadSpy).not.toHaveBeenCalledWith('other-team'); expect(refreshMemberActivityMetaSpy).not.toHaveBeenCalledWith('other-team'); }); it('member-spawn refreshes spawn statuses without forcing structural refresh', async () => { const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses'); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); hoisted.onTeamChangeCb?.({}, { type: 'member-spawn', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(500); expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team'); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); }); it('inbox/config/process do not refresh member spawn statuses by default', async () => { const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses'); hoisted.onTeamChangeCb?.({}, { type: 'inbox', teamName: 'my-team' }); hoisted.onTeamChangeCb?.({}, { type: 'config', teamName: 'my-team' }); hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(800); expect(fetchMemberSpawnStatusesSpy).not.toHaveBeenCalled(); }); it('lead-message does not call fetchAllTasks', async () => { const fetchAllTasksSpy = vi.fn(async () => undefined); useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(2100); expect(fetchAllTasksSpy).not.toHaveBeenCalled(); }); it('fallback polling refreshes hidden teams with an active pending-reply wait state', async () => { useStore.getState().syncTeamPendingReplyRefresh('other-team', 'tab-hidden', true, 60_000); const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta'); await vi.advanceTimersByTimeAsync(10_000); expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team'); expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team'); }); it('log-source-change refreshes only task change presence', async () => { useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, } as never); const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); const refreshTeamChangePresenceSpy = vi.spyOn(state, 'refreshTeamChangePresence'); hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(399); expect(refreshTeamChangePresenceSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(refreshTeamChangePresenceSpy).toHaveBeenCalledTimes(1); expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team'); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(fetchTeamsSpy).not.toHaveBeenCalled(); }); it('log-source-change refreshes visible graph tab change presence for non-selected teams', async () => { useStore.setState({ selectedTeamName: 'other-team', selectedTeamData: { teamName: 'other-team', config: { name: 'Other Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, teamDataCacheByName: { 'my-team': { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, }, paneLayout: { focusedPaneId: 'p1', panes: [ { id: 'p1', widthFraction: 1, tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }], activeTabId: 'g1', }, ], }, } as never); const refreshTeamChangePresenceSpy = vi.spyOn(useStore.getState(), 'refreshTeamChangePresence'); hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(400); expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team'); }); it('keeps background polling disabled for unknown in-progress tasks', async () => { const invalidateTaskChangePresence = vi.fn(); const checkTaskHasChanges = vi.fn(async () => undefined); useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [ { id: 'task-1', owner: 'alice', status: 'in_progress', createdAt: '2026-03-01T10:00:00.000Z', updatedAt: '2026-03-01T10:00:00.000Z', workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], historyEvents: [], reviewState: 'none', changePresence: 'unknown', }, { id: 'task-2', owner: 'alice', status: 'in_progress', createdAt: '2026-03-01T10:00:00.000Z', updatedAt: '2026-03-01T10:00:00.000Z', workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], historyEvents: [], reviewState: 'none', changePresence: 'unknown', }, ], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, invalidateTaskChangePresence, checkTaskHasChanges, } as never); await vi.advanceTimersByTimeAsync(20_000); expect(checkTaskHasChanges).not.toHaveBeenCalled(); }); it('keeps background polling disabled for visible non-selected graph teams', async () => { const invalidateTaskChangePresence = vi.fn(); const checkTaskHasChanges = vi.fn(async () => undefined); useStore.setState({ selectedTeamName: 'other-team', selectedTeamData: { teamName: 'other-team', config: { name: 'Other Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, teamDataCacheByName: { 'my-team': { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [ { id: 'task-1', owner: 'alice', status: 'in_progress', createdAt: '2026-03-01T10:00:00.000Z', updatedAt: '2026-03-01T10:00:00.000Z', workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], historyEvents: [], reviewState: 'none', changePresence: 'unknown', }, { id: 'task-2', owner: 'alice', status: 'in_progress', createdAt: '2026-03-01T10:00:00.000Z', updatedAt: '2026-03-01T10:00:00.000Z', workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], historyEvents: [], reviewState: 'none', changePresence: 'unknown', }, ], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, }, paneLayout: { focusedPaneId: 'p1', panes: [ { id: 'p1', widthFraction: 1, tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }], activeTabId: 'g1', }, ], }, invalidateTaskChangePresence, checkTaskHasChanges, } as never); await vi.advanceTimersByTimeAsync(20_000); expect(checkTaskHasChanges).not.toHaveBeenCalled(); }); it('per-team throttling: busy team does not block another visible team', async () => { // Add a second visible team tab useStore.setState({ paneLayout: { focusedPaneId: 'p1', panes: [ { id: 'p1', widthFraction: 0.5, tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }], activeTabId: 't1', }, { id: 'p2', widthFraction: 0.5, tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }], activeTabId: 't2', }, ], }, } as never); const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead'); // Fire rapid events for my-team (throttled) hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); // Fire event for other-team — should NOT be blocked by my-team's throttle hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' }); await vi.advanceTimersByTimeAsync(800); // Both teams should get exactly 1 refresh each expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(2); expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team'); expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team'); }); it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => { const setChangePresenceTrackingSpy = vi.mocked(api.teams.setChangePresenceTracking); setChangePresenceTrackingSpy.mockClear(); expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled(); useStore.setState({ selectedTeamName: 'my-team', selectedTeamData: { teamName: 'my-team', config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, } as never); await Promise.resolve(); expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled(); useStore.setState({ selectedTeamName: 'other-team', selectedTeamData: null, paneLayout: { focusedPaneId: 'p1', panes: [ { id: 'p1', widthFraction: 1, tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }], activeTabId: 't2', }, ], }, } as never); await Promise.resolve(); expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled(); }); it('tracks visible team tabs for tool activity and disables tracking when tab disappears', async () => { const setToolActivityTrackingSpy = vi.mocked(api.teams.setToolActivityTracking); setToolActivityTrackingSpy.mockClear(); cleanup?.(); cleanup = initializeNotificationListeners(); await vi.advanceTimersByTimeAsync(0); expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', true); useStore.setState({ paneLayout: { focusedPaneId: 'p1', panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], }, } as never); await vi.advanceTimersByTimeAsync(0); expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false); }); it('tracks visible graph tabs for tool activity and disables tracking when graph tab disappears', async () => { const setToolActivityTrackingSpy = vi.mocked(api.teams.setToolActivityTracking); setToolActivityTrackingSpy.mockClear(); useStore.setState({ paneLayout: { focusedPaneId: 'p1', panes: [ { id: 'p1', widthFraction: 1, tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }], activeTabId: 'g1', }, ], }, } as never); cleanup?.(); cleanup = initializeNotificationListeners(); await vi.advanceTimersByTimeAsync(0); expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', true); useStore.setState({ paneLayout: { focusedPaneId: 'p1', panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], }, } as never); await vi.advanceTimersByTimeAsync(0); expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false); }); it('tracks visible team tabs for task log activity and disables tracking when tab disappears', async () => { const setTaskLogStreamTrackingSpy = vi.mocked(api.teams.setTaskLogStreamTracking); setTaskLogStreamTrackingSpy.mockClear(); cleanup?.(); cleanup = initializeNotificationListeners(); await vi.advanceTimersByTimeAsync(0); expect(setTaskLogStreamTrackingSpy).toHaveBeenCalledWith('my-team', true); useStore.setState({ paneLayout: { focusedPaneId: 'p1', panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], }, } as never); await vi.advanceTimersByTimeAsync(0); expect(setTaskLogStreamTrackingSpy).toHaveBeenCalledWith('my-team', false); }); it('pulses task log activity only for real log signals and clears it after inactivity', async () => { hoisted.onTeamChangeCb?.({}, { type: 'task-log-change', teamName: 'my-team', taskId: 'task-change-only', taskSignalKind: 'change', }); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); useStore.setState({ currentRuntimeRunIdByTeam: { 'my-team': 'run-current' } } as never); hoisted.onTeamChangeCb?.({}, { type: 'task-log-change', teamName: 'my-team', runId: 'run-old', taskId: 'task-stale', taskSignalKind: 'log', }); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); hoisted.onTeamChangeCb?.({}, { type: 'task-log-change', teamName: 'my-team', runId: 'run-current', taskId: 'task-live', taskSignalKind: 'log', }); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ 'task-live': true, }); await vi.advanceTimersByTimeAsync(3499); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ 'task-live': true, }); await vi.advanceTimersByTimeAsync(1); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); }); it('pulses visible task log activity without refreshing team data for explicit log signals', async () => { const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'task-log-change', teamName: 'my-team', taskId: 'task-live', taskSignalKind: 'log', } ); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ 'task-live': true, }); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); }); it('refreshes visible team data for task change freshness without pulsing live log activity', async () => { const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'task-log-change', teamName: 'my-team', taskId: 'task-completed', taskSignalKind: 'change', } ); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); }); it('keeps the bounded team data refresh for legacy task log change events', async () => { const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'task-log-change', teamName: 'my-team', taskId: 'task-live', detail: 'opencode-runtime-task-event:start', } ); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ 'task-live': true, }); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); }); it('skips the bounded task log refresh if the team is hidden before execution', async () => { const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); hoisted.onTeamChangeCb?.( {}, { type: 'task-log-change', teamName: 'my-team', taskId: 'task-live', taskSignalKind: 'log', } ); useStore.setState({ paneLayout: { focusedPaneId: 'p1', panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], }, } as never); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); }); it('extends task log activity pulse on repeated log signals and ignores hidden teams', async () => { const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); const activitySnapshots: Array | undefined> = []; const unsubscribeActivitySnapshots = useStore.subscribe((nextState, prevState) => { if (nextState.activeTaskLogActivityByTeam !== prevState.activeTaskLogActivityByTeam) { activitySnapshots.push(nextState.activeTaskLogActivityByTeam['my-team']); } }); hoisted.onTeamChangeCb?.({}, { type: 'task-log-change', teamName: 'my-team', taskId: 'task-live', taskSignalKind: 'log', }); expect(activitySnapshots).toEqual([{ 'task-live': true }]); await vi.advanceTimersByTimeAsync(2000); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); hoisted.onTeamChangeCb?.({}, { type: 'task-log-change', teamName: 'my-team', taskId: 'task-live', taskSignalKind: 'log', }); expect(activitySnapshots).toEqual([{ 'task-live': true }]); await vi.advanceTimersByTimeAsync(3499); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ 'task-live': true, }); await vi.advanceTimersByTimeAsync(1); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); expect(activitySnapshots).toEqual([{ 'task-live': true }, undefined]); useStore.setState({ paneLayout: { focusedPaneId: 'p1', panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], }, } as never); hoisted.onTeamChangeCb?.({}, { type: 'task-log-change', teamName: 'my-team', taskId: 'task-hidden', taskSignalKind: 'log', }); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); await vi.advanceTimersByTimeAsync(800); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); unsubscribeActivitySnapshots(); }); it('applies targeted tool resets without clearing sibling tools', async () => { useStore.setState({ activeToolsByTeam: { 'my-team': { alice: { 'tool-a': { memberName: 'alice', toolUseId: 'tool-a', toolName: 'Read', startedAt: '2026-03-28T10:00:00.000Z', state: 'running', source: 'runtime', }, 'tool-b': { memberName: 'alice', toolUseId: 'tool-b', toolName: 'Bash', startedAt: '2026-03-28T10:00:01.000Z', state: 'running', source: 'runtime', }, }, }, }, } as never); hoisted.onTeamChangeCb?.({}, { type: 'tool-activity', teamName: 'my-team', detail: JSON.stringify({ action: 'reset', memberName: 'alice', toolUseIds: ['tool-a'], }), }); expect(useStore.getState().activeToolsByTeam['my-team']?.alice?.['tool-a']).toBeUndefined(); expect(useStore.getState().activeToolsByTeam['my-team']?.alice?.['tool-b']).toBeDefined(); }); });